C---测试驱动开发-全-

C++ 测试驱动开发(全)

原文:zh.annas-archive.org/md5/926fd2f5982d529b0df13c71daf02822

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

多年前,当我想要学习一种新技术或开发一项新技能时,我开始避免使用食谱风格的书籍。一本书应该引导我们理解基本概念,以便我们能够调整学习以适应我们的需求。我们不应该因为一个食谱与我们的目标不完全匹配而迷失方向。

我也倾向于避免那些充满理论的书籍。你可能知道我的意思。这些书籍不是解释如何使用一种技术,而是提到了一些想法,但没有探讨将这些想法付诸实践的方法。专注于这两方面的任何一个都可能使学习体验不完整。

这本书与众不同。它不会像食谱一样给你几个简单的例子,也不会一味地讲述规则和定义,而是会向你展示如何使用测试来编写执行以下任务的软件:

  • 用户期望的,并且轻松实现

  • 让你能够添加新功能而不会破坏你已完成的工作

  • 清晰地传达代码的功能

我们将从零开始构建多个项目。你将通过构建一个简单且自然的 C++测试编写方式来学习测试是什么以及它如何帮助。这被称为测试驱动开发(TDD),你将使用 TDD 来开发项目所需的各种工具。

为了从测试驱动开发(TDD)中获得最大益处,你需要一种编写和运行测试的方法。一些编程语言已经支持 TDD,而 C++则需要额外的工具和库来支持测试。我本可以简单地解释如何使用现有的各种 TDD 工具。那将是一种食谱式的方法,因为每个现有解决方案都有细微的差别。从零开始构建你需要的东西,你将学习 TDD,同时你也会了解所有其他解决方案是如何工作的。概念是相同的。

例如,大多数学习开车的人都能驾驶皮卡或面包车。一些细微的差异是可以预料的,但概念是相同的。骑摩托车可能更具挑战性,但许多相同的概念仍然有效。驾驶公交车可能需要一些额外的培训,但也是相似的。

如果你决定使用其他工具或甚至其他语言,通过遵循本书中的步骤,你将深入了解那些其他工具是如何工作的。其他工具就像驾驶皮卡一样。

本书分为三部分。在第一部分,你将构建一个测试库,这将让你能够编写和运行测试。你将学习如何一步一步地进行,让测试引导你。

第二部分将使用测试库开始一个新的项目,帮助你记录软件运行时的消息。这是一个日志库,这个项目将向你展示如何构建满足目标客户需求的软件。你将向日志库添加许多功能,你将学习如何在开发更多功能的同时保持一切正常运行。

日志库的目标客户是 C++微服务开发者,这种客户焦点延伸到第三部分,在那里你将扩展测试库并构建一个简单的服务。该服务将用于解释软件开发中最困难的一个方面:如何测试多线程。

三部分将结合起来向你展示如何使用 TDD 有效地设计和编写满足客户需求的软件,让你有信心进行更改,并让其他团队成员理解设计。

这本书面向的对象

这本书是为那些已经熟悉并使用 C++进行日常任务的 C++开发者所写。你不需要是专家,但你应该已经了解一些现代 C++以及如何使用模板来充分利用这本书。

如果你曾经努力向大型项目添加新功能,而没有在其他地方破坏软件,那么这本书可以帮助你。你将成为一名更好的软件开发者,避免修复错误或进行设计更改的压力和担忧。TDD 是一种指导你创建直观设计的过程,用户将理解并享受这些设计。

如果你曾经犹豫过是否进行改进,并决定保留过时且令人困惑的设计,因为担心它可能会出错,那么这本书可以帮助你。也许你有一个好主意,但你知道你的经理永远不会同意,因为风险太高。如果你的项目遵循这本书解释的 TDD 实践,那么你可以更有信心地进行改进。你可以让测试证明你的想法是可靠的。

你是否有新团队成员需要快速了解你的软件是如何工作的?这本书探讨的过程将帮助你编写 TDD 测试,以记录软件设计和需求。你甚至可能会发现,测试有助于你自己的理解,尤其是在返回长时间未参与的项目时。

C++是一种功能强大且需要考虑很多细节的语言。这本书通过 TDD 帮助简化软件开发过程。即使你已经知道如何使用一个或多个 TDD 工具,这本书也会提高你的技能,并让你有信心在更高级或更大的项目中应用 TDD。

这本书涵盖的内容

第一章期望的测试声明,从空项目开始使用 C++编写你的第一个测试。

第二章测试结果,报告并使用测试结果,以便你可以快速了解项目的哪些部分正在工作,哪些部分正在失败。

第三章TDD 过程,通过识别你已经一直在做的步骤,有目的地使用 TDD。

第四章向项目中添加测试,通过判断你的期望是否得到满足来增强测试的通过或失败。

第五章添加更多确认类型,通过赋予你检查任何值类型是否满足预期的能力,使测试更加高效。

第六章早期探索改进,寻找其他改进,并展示你如何退后一步探索你可能之前没有考虑过的想法。

第七章测试设置和清理,准备测试运行并在之后进行清理,这样你可以将注意力集中在测试需要做什么上,这有助于使测试更容易理解。

第八章什么是一个好的测试?,提供了来自其他章节的建议和学习,以巩固你所学的内容,并鼓励你通过后续章节中主题的暗示继续学习。

第九章使用测试,构建了一个日志库,将你迄今为止所学的一切应用到实际中。

第十章深入理解 TDD 过程,通过使用 TDD 向增长的项目添加功能,包括如何处理设计变更进行实践。

第十一章管理依赖项,添加了可交换的功能,展示了如何测试不可靠的软件组件,以及在没有所有必需组件的情况下如何取得进展。

第十二章创建更好的测试确认,使用 Hamcrest 匹配器通过让你更自然地验证预期来改进测试。

第十三章如何测试浮点数和自定义值,展示了如何可靠地使用浮点数,以及如何扩展 Hamcrest 匹配器以满足自定义需求。

第十四章如何测试服务,涵盖了服务如何不同以及如何测试服务。

第十五章如何使用多线程进行测试,简化并协调多线程测试,以便你在测试每个可能的线程交互时能够可靠且可预测地避免竞态条件。

要充分利用这本书

你需要一个能够构建 C++20 或更高版本代码的现代 C++编译器。本书中的所有内容都使用标准 C++,并且可以在任何计算机上运行。所有输出都是文本。

这本书允许你使用你最舒适的构建系统来构建代码。项目文件数量很少,文件夹结构简单。你可以在代码编辑器中轻松创建项目并跟随。

这本书不描述完成的项目。每个项目都是一段旅程。第一章从空项目开始,后续的每一章都会在前一章的基础上添加代码。鼓励你跟随这个过程。

记住要学习这个过程。这比每一章解释的实际代码更重要。本书多次提到了 TDD(测试驱动开发)的过程。前几章向您介绍了 TDD,直到测试库中有足够的功能,以便可以使用测试库构建另一个项目。第二个日志库项目更深入和详细地探讨了 TDD 的过程。一旦日志库可用,就可以使用测试和日志库来构建一个简单的服务项目。TDD 的过程中有一个重复的模式。通过每个项目学习 TDD 过程将给您带来最大的收益。

因为项目是逐步构建的,您也可以从沿途解释的错误中受益。有时,设计会发生变化,您也可以从变化的原因中受益,以及学习如何管理变化。

当我说您需要一个 C++20 编译器时,这是一个简化。编译器供应商在其编译器的不同版本中支持 C++ 的许多不同功能。一个很好的规则是确保您的编译器支持 C++20 的 概念。我们在本书的最后几章使用了概念,如果您的编译器支持概念,那么您应该拥有您所需的一切。一个很好的阅读链接是:

en.cppreference.com/w/cpp/compiler_support

当您访问链接时,滚动到 C++20 部分,寻找标识概念功能的行。在撰写本文时,以下编译器应该可以工作:

  • GCC 版本 10

  • Clang 版本 10

  • MSVC 版本 19.30

  • Apple Clang 版本 12(然而,这个编译器只有部分实现)

您可能会使用与您的编译器一起提供的任何版本的 C++ 标准库。然而,另一个应该适用的好规则是确保您的标准库也支持概念。在撰写本文时,以下标准库应该可以工作:

  • GCClibstdc++ 版本 10

  • Clang libc++ 版本 13

  • MSVC STL 版本 19.23

  • Apple Clang 版本 13.1.6

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Test-Driven-Development-with-CPP)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“这就是为什么我们现在的确认宏被称为CONFIRM_TRUECONFIRM_FALSE。”

代码块应如下设置:

TEST("Test bool confirms")
{
    bool result = isNegative(0);
    CONFIRM_FALSE(result);
    result = isNegative(-1);
    CONFIRM_TRUE(result);
}

任何命令行输入或输出都应如下所示:

Running 3 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Failed
Expected exception type int was not thrown.
---------------
Tests passed: 2
Tests failed: 1
Program ended with exit code: 1

粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“我们将只将状态从失败更改为预期失败。”

提示或重要注意事项

它看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果你在互联网上遇到我们作品的任何形式的非法副本,我们非常感谢你提供位置地址或网站名称。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享你的想法

一旦你阅读了《使用 C++进行测试驱动开发》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?你的电子书购买是否与你的选择设备不兼容?

不要担心,现在,每当你购买 Packt 书籍时,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此结束,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取这些好处:

  1. 扫描二维码或访问下面的链接

packt.link/free-ebook/9781803242002

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件

第一部分:测试最小可行产品

本书分为三个部分。在这个第一部分,您将了解测试驱动开发的重要性以及如何使用它来帮助您设计和编写软件。我们将从一个空项目开始,并使用测试驱动开发实践来设计和构建单元测试库。通过跟随我们的探索和逐步构建一个可工作的项目,您将学习到您需要了解的一切。

以下章节将在本部分介绍:

  • 第一章, 期望的测试声明

  • 第二章, 测试结果

  • 第三章, TDD 过程

  • 第四章, 将测试添加到项目中

  • 第五章, 添加更多确认类型

  • 第六章, 早期探索改进

  • 第七章, 测试设置和拆卸

  • 第八章, 什么是一个好的测试?

第一章:所需测试声明

如果我们要有一个测试驱动开发TDD)的过程,我们需要测试。本章将解释测试将做什么,我们将如何编写它们,以及我们将如何使用它们。

我们将从一开始慢慢构建一个完整的库来帮助管理和运行测试,并且我们将使用测试库来帮助构建自己。最初,将只有一个测试。接下来的章节将添加更多功能并扩展测试库。

从最终目标开始考虑,我们首先会思考创建和使用测试会是什么样的。编写测试是 TDD 的一个重要部分,所以在我们能够创建和运行测试之前,考虑测试是有意义的。

TDD(测试驱动开发)是一个过程,它将帮助你设计更好的代码,然后在不破坏你已验证为按预期工作的部分的情况下修改你的代码。为了使这个过程有效,我们需要能够编写测试。本章将探讨测试能为我们做什么,以及我们如何编写它们。

本章将涵盖以下主要主题:

  • 我们希望测试为我们做什么?

  • 测试应该是什么样子?

  • 测试需要哪些信息?

  • 我们如何使用 C++编写测试?

  • 第一份测试将如何被使用?

技术要求

本章中使用的所有代码都是基于标准的 C++,它构建在任何现代 C++ 17 或更高版本的编译器和标准库之上。未来的章节将需要 C++ 20,但到目前为止,只需要 C++ 17。数字指的是标准被批准和最终确定的那一年,因此 C++ 17 于 2017 年发布,C++ 20 于 2020 年发布。每次发布都会为语言添加新的功能和能力。

我们将工作的代码从一个空的控制台项目开始,该项目有一个名为main.cpp的单个源文件。

如果你的开发环境在启动新的命令行或控制台项目时给你一个“Hello, world!”项目,你可以删除main.cpp文件的内容,因为本章将从空文件开始从头开始。

你可以在以下 GitHub 仓库找到本章的所有代码:github.com/PacktPublishing/Test-Driven-Development-with-CPP

我们希望测试为我们做什么?

在我们开始学习测试驱动开发、它是什么以及涉及的过程之前,让我们退一步思考我们想要什么。在不了解测试的所有细节的情况下,让我们问问自己我们的测试应该是什么样子。

我喜欢尽可能地将编程概念与日常经验联系起来。也许你有一个解决问题的想法,你注意到并想看看你的想法是否可行。如果你想在向世界宣布之前测试这个想法,你会怎么做?

你可能无法一次性测试你想法的所有方面。这又意味着什么呢?你可能会先考虑你想法的一些小部分。这些部分应该更容易测试,并且有助于阐明你的想法,让你思考其他可以测试的事情。

因此,让我们专注于简单地测试想法的小部分,无论它是什么。你想要设置好一切,然后开始一些行动或步骤,这些行动或步骤应该告诉你每个部分是否工作。一些测试可能工作得很好,而一些测试可能会让你重新思考你的想法。这肯定比在没有知道它是否可行的情况下跳入整个想法要好。

为了将这个概念应用到实际情境中,假设你有一个想法要制造一把更好的扫帚。这是一个模糊的想法,难以想象。然而,假设你在最近打扫地板时,注意到你的手臂酸痛,并认为肯定有更好的方法。思考实际问题是一个将模糊想法转化为更有实质性意义的好方法。

现在,你可能会开始考虑测试不同形状的扫帚柄、不同的握把或不同的扫地动作。这些都是可以测试的想法的小部分。你可以将每个握把或动作转换成一系列步骤或行动,以测试该部分,直到找到最佳的工作方式。

好吧,在编程中,一系列步骤可以是一个函数。现在这个函数做什么并不重要。我们可以将每个测试想象成一个函数。如果你可以调用一个函数并且它给出了预期的结果,那么你可以说测试通过了。我们将在整本书中基于这个想法进行构建。

既然我们已经决定使用函数来进行测试,它应该是什么样子呢?毕竟,编写函数有很多种方式。

测试应该是什么样子?

编写测试应该和声明和编写函数一样简单,我们应该能够进一步简化事情。一个普通函数可以有任何你想要的返回类型,一个名称,一组参数,以及一段代码体。

函数也是你编写的东西,以便其他代码可以调用它。这段代码应该知道函数做什么,它返回什么,以及需要传递什么参数。我们将保持测试函数的简单性,现在只关注名称。

我们希望每个测试函数都有自己的名称。否则,我们如何能够跟踪我们最终将要编写的所有各种测试呢?至于返回类型,我们还没有确定实际需求,所以我们将使用void

你将在第三章《TDD 过程》中了解更多关于这个流程。在使用 TDD 时,不要急于求成。只做当时需要做的事情。就像void返回类型一样,我们也不会有任何参数。

这可能看起来太简单了,但这是一个好的开始。到目前为止,测试不过是一个函数,它不返回任何内容,也不接受任何参数。它有一个名称来识别它,并将包含运行测试所需的任何代码。

由于我们将开始使用 TDD 来帮助设计一个简单的测试库,我们的第一个测试应该确保我们可以创建一个测试。这是一个简单的开始,它定义了一个测试函数并从main中调用它。所有这些都在一个名为main.cpp的单个文件中:

#include <iostream>
void testCanBeCreated ()
{
    std::cout << "testCanBeCreated" << std::endl;
}
int main ()
{
    testCanBeCreated();
    return 0;
}

你可能会想,这根本不是测试,而只是一个打印自己名称的函数,你是对的。我们将从头开始,以敏捷的方式构建它,只使用我们目前拥有的资源。现在,我们还没有可用的测试库。

然而,这已经开始类似于我们最终想要的样子了。我们希望测试就像编写一个函数一样。如果你现在构建并运行项目,输出应该是预期的:

testCanBeCreated
Program ended with exit code: 0

这显示了运行程序时的输出。它显示了函数的名称。第二行的文本实际上来自我的开发工具,显示了程序的退出代码。退出代码是main函数返回的值。

这是一个开始,但可以改进。下一节将探讨测试需要哪些信息,例如其名称。

测试需要哪些信息?

当前的测试函数实际上并不知道自己的名称。我们希望测试有一个名称以便于识别,但这个名称真的需要是函数的名称吗?如果这个名称可以作为数据提供,以便在测试体内部不硬编码名称的情况下显示,那就更好了。

同样,当前的测试函数对成功或失败没有任何概念。我们故意忽略了测试结果,直到现在,但让我们考虑一下。一个测试函数返回状态是否足够?也许它需要一个bool返回类型,其中true表示成功,而false表示测试失败。

这可能有点过于简单化了。当然,现在可能足够了,但如果测试失败,了解失败的原因可能就很重要了。仅仅返回bool类型在以后可能就不够了。我们不需要设计整个解决方案,只需要弄清楚要做什么才能满足预期的需求。

既然我们已经知道我们需要一些数据来保存测试名称,那么我们现在是否可以在相同的位置添加简单的bool结果数据呢?这样我们可以保持测试函数的返回类型为void,并为以后更高级的解决方案留出空间。

让我们将测试函数改为operator(),如下所示:

#include <iostream>
#include <string_view>
class Test
{
public:
    Test (std::string_view name)
    : mName(name), mResult(true)
    {}
    void operator () ()
    {
        std::cout << mName << std::endl;
    }
private:
    std::string mName;
    bool mResult;
};
Test test("testCanBeCreated");
int main ()
{
    test();
    return 0;
}

这个问题的最大问题是,我们不再有简单的方式来编写一个测试,就像它是一个简单的函数一样。通过提供operator (),或者称为函数调用操作符,我们创建了一个可以让我们在main函数内部将类当作函数来调用的函数对象。然而,这需要编写更多的代码。它解决了测试名称的问题,为我们提供了一个简单的解决方案,这个方案可以稍后扩展,并且还解决了之前不明显的问题。

在我们之前在main函数中调用测试函数时,我们必须通过函数名来调用它。这是代码中调用函数的方式,对吧?这种新的设计通过创建一个名为testTest函数对象的实例来消除这种耦合。现在,main不再关心测试名称。它只引用函数对象的实例。测试名称现在在代码中唯一出现的地方是在创建函数对象实例时。

我们可以通过使用来解决编写测试时所需的所有额外代码问题。宏在 C++中不再像以前那样需要,有些人甚至认为应该从语言中完全移除它们。它们仍然有一些好的用途,将代码封装到宏中就是其中之一。

我们最终会将宏定义放入一个单独的头文件中,这将成为测试库。我们想要做的是在宏中将所有函数对象代码封装起来,但将实际测试函数体的实现留给像正常函数一样编写。

首先,我们将对测试函数体的实现进行简单的修改,将其移出类定义之外,如下所示。需要移出的方法是函数调用操作符:

class Test
{
public:
    Test (std::string_view name)
    : mName(name), mResult(true)
    {}
    void operator () ();
private:
    std::string mName;
    bool mResult;
};
Test test("testCanBeCreated");
void Test::operator () ()
{
    std::cout << mName << std::endl;
}

然后,类定义、实例声明和函数调用操作符的第一行可以被转换成一个宏。将以下代码与之前的代码进行比较,看看Test类是如何被转换成TEST宏的。仅凭这个宏本身是无法编译的,因为它将函数调用操作符留在了未完成的状态。这正是我们想要的,因为它允许代码像函数签名声明一样使用宏,并通过提供花括号和方法的实现来完成它:

#define TEST class Test \
{ \
public: \
    Test (std::string_view name) \
    : mName(name), mResult(true) \
    {} \
    void operator () (); \
private: \
    std::string mName; \
    bool mResult; \
}; \
Test test("testCanBeCreated"); \
void Test::operator () ()
TEST
{
    std::cout << mName << std::endl;
}

因为宏定义跨越多行,除了最后一行之外,每一行都需要以反斜杠结尾。由于删除了空行,这个宏变得更加紧凑。这是一个个人选择,如果你想的话可以保留空行。但是,空行仍然需要反斜杠,这违背了保留空行的目的。

代码使用TEST宏和未完成的函数调用操作符,就像函数定义一样,然后通过提供花括号和方法实现来完成代码。

我们正在取得进展!可能很难看到,因为所有内容都在一个文件中。让我们通过创建一个名为 Test.h 的新文件并将宏定义移动到新文件来解决这个问题,如下所示:

#ifndef TEST_H
#define TEST_H
#include <string_view>
#define TEST class Test \
{ \
public: \
    Test (std::string_view name) \
    : mName(name), mResult(true) \
    {} \
    void operator () (); \
private: \
    std::string mName; \
    bool mResult; \
}; \
Test test("testCanBeCreated"); \
void Test::operator () ()
#endif // TEST_H

现在,我们可以回到 main.cpp 中的简单代码,如下所示的下一段代码所示。我们只需要包含 Test.h 并使用宏:

#include "Test.h"
#include <iostream>
TEST
{
    std::cout << mName << std::endl;
}
int main ()
{
    test();
    return 0;
}

现在我们有了一些开始看起来像我们最初开始时的简单函数,但 TEST 宏内部隐藏了大量的代码,使其看起来简单。

在下一节中,我们将解决 main 直接调用 test() 的需求。函数名 test 是一个不应该在宏之外知道细节,我们绝对不需要直接调用一个测试来运行它,无论它叫什么名字。

我们如何使用 C++ 编写测试?

直接调用测试可能现在看起来不是什么大问题,因为我们只有一个测试。然而,随着测试数量的增加,需要从 main 中逐个调用每个测试的需求将导致问题。你真的希望每次添加或删除测试时都要修改 main 函数吗?

C++ 语言没有添加额外自定义信息到函数或类的方法,这些信息可以用来识别所有测试。因此,没有方法可以遍历所有代码,自动找到所有测试并运行它们。

C++ 的一个原则是避免添加你可能不需要的语言特性,特别是那些在你不知情的情况下影响你代码的语言特性。其他语言可能允许你做其他事情,例如添加自定义属性,你可以使用这些属性来识别测试。C++ 定义了标准属性,这些属性旨在帮助编译器优化代码执行或改进代码的编译。标准的 C++ 属性不是我们可以用来识别测试的东西,而自定义属性将违反无需特性的原则。我喜欢 C++ 的这一点,即使这意味着我们必须更努力地找出要运行的测试。

我们需要做的只是让每个测试能够识别自己。这与编写试图找到测试的代码不同。找到测试需要以某种方式标记它们,例如使用属性,以便它们突出显示,但在 C++ 中这是不可能的。我们不是去找到它们,而是可以使用每个测试函数的构造函数,使它们注册自己。每个测试的构造函数将通过将指向自己的指针推送到集合中,将自己添加到注册表中。

一旦所有测试都通过添加到集合中进行了注册,我们就可以遍历集合并运行它们。我们已经简化了测试,以便它们都可以以相同的方式运行。

我们需要注意的一个复杂问题是。在 TEST 宏中创建的测试实例是全局变量,并且可以分布在许多不同的源文件中。目前,我们在单个 main.cpp 源文件中声明了一个测试。我们需要确保在开始尝试将测试添加到集合之前,将最终包含所有已注册测试的集合设置好并准备好。我们将使用一个函数来帮助协调设置。这是 getTests 函数,如下所示。getTests 的工作方式并不明显,将在下一部分代码之后进行更详细的描述。

现在也是开始考虑 MereTDD 的好时机。

这是 Test.h 文件的第一部分,其中添加了新的命名空间和注册代码。我们还应该更新 include 守卫,使其更加具体,例如 MERETDD_TEST_H,如下所示:

#ifndef MERETDD_TEST_H
#define MERETDD_TEST_H
#include <string_view>
#include <vector>
namespace MereTDD
{
class TestInterface
{
public:
    virtual ~TestInterface () = default;
    virtual void run () = 0;
};
std::vector<TestInterface *> & getTests ()
{
    static std::vector<TestInterface *> tests;
    return tests;
}
} // namespace MereTDD

在命名空间内部,声明了一个新的 TestInterface 类,其中包含一个 run 方法。我决定放弃使用函数式对象,转而采用这种新设计,因为当我们需要稍后实际运行测试时,有一个名为 run 的方法看起来更直观、更容易理解。

测试集合存储在一个 TestInterface 指针的 vector 中。这是一个使用原始指针的好地方,因为没有隐含的所有权。集合将不会负责删除这些指针。向量在 getTests 函数内部声明为 静态 变量。这是为了确保向量得到适当的初始化,即使它首先是从另一个 .cpp 源文件编译单元访问的。

C++ 语言确保在 main 开始之前初始化 全局变量。这意味着我们在 test 实例构造函数中有代码在 main 开始之前运行。当我们有多个 .cpp 文件时,确保集合首先初始化变得很重要。如果集合是一个普通的全局变量,并且直接从另一个编译单元访问,那么可能是在测试尝试将自己推送到集合时,集合尚未准备好。尽管如此,通过通过 getTests 函数,我们避免了准备就绪问题,因为编译器将确保在 函数第一次被调用时 初始化静态向量。

我们需要在宏中使用时,将类和函数的引用限制在命名空间内。以下是 Test.h 的最后一部分,其中对宏进行了修改以使用命名空间:

#define TEST \
class Test : public MereTDD::TestInterface \
{ \
public: \
    Test (std::string_view name) \
    : mName(name), mResult(true) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void run () override; \
private: \
    std::string mName; \
    bool mResult; \
}; \
Test test("testCanBeCreated"); \
void Test::run ()
#endif // MERETDD_TEST_H

Test 构造函数现在通过调用 getTests 并将指向自身的指针推回到它获得的向量中来注册自己。现在编译的是哪个 .cpp 文件并不重要。一旦 getTests 返回向量,测试集合将被完全初始化。

TEST 宏仍然位于命名空间之外,因为它在这里不会被编译。它只有在宏被使用时才会被插入到其他代码中。这就是为什么在宏内部,现在需要使用 MereTDD 命名空间来限定 TestInterfacegetTests 调用的原因。

main.cpp 中,唯一的变化是如何调用测试。我们不再直接引用测试实例,而是遍历所有测试并对每个测试调用 run。这就是我决定使用名为 run 的方法而不是函数调用操作符的原因:

int main ()
{
    for (auto * test: MereTDD::getTests())
    {
        test->run();
    }
    return 0;
}

我们可以进一步简化这一点。main 中的代码似乎需要了解太多关于测试如何运行的信息。让我们创建一个名为 runTests 的新函数来包含 for 循环。我们可能以后需要增强 for 循环,这似乎应该是测试库内部的。以下是 main 现在应该看起来像什么:

int main ()
{
    MereTDD::runTests();
    return 0;
}

我们可以通过在命名空间内添加 runTests 函数来启用此更改,如下所示:

namespace MereTDD
{
class TestInterface
{
public:
    virtual ~TestInterface () = default;
    virtual void run () = 0;
};
std::vector<TestInterface *> & getTests ()
{
    static std::vector<TestInterface *> tests;
    return tests;
}
void runTests ()
{
    for (auto * test: getTests())
    {
        test->run();
    }
}
} // namespace MereTDD

在所有这些更改之后,我们有一个简化的 main 函数,它只是调用测试库来运行所有测试。它对运行了哪些测试或如何运行一无所知。尽管我们仍然只有一个测试,但我们正在创建一个能够支持多个测试的坚实设计。

下一节将解释如何通过查看第一个测试来使用测试。

首个测试将如何被使用?

到目前为止,我们有一个在运行时输出其名称的单个测试,并且这个测试是在 main.cpp 内部声明的。这不是你未来想要声明测试的方式。我提到了拥有多个 .cpp 文件,每个文件中都有多个测试。我们还没有准备好这样做,但我们至少可以将我们拥有的单个测试移动到它自己的 .cpp 文件中。

在多个 .cpp 文件中声明多个测试的整个目的是为了帮助你组织测试。将它们组合成有意义的类别。我们稍后会讨论多个测试。现在,我们单次测试的目的是什么?

这本应表明可以创建测试。我们可能对测试创建的其他方面也感兴趣。因此,创建一个专注于测试创建的 .cpp 文件可能是有意义的。在这个 .cpp 文件中,将包含所有与 创建测试的不同方式 相关的测试。

你可以按照自己的意愿组织测试。如果你正在处理一个拥有自己源文件集的项目,那么围绕源文件组织测试可能是有意义的。因此,你将会有一个包含许多测试的测试 .cpp 文件,这些测试都是为了测试你实际项目中的 .cpp 文件相关的所有内容。如果你的项目文件已经组织得很好,这样做是有意义的。

或者,你可能采取一种更函数式的方法来组织你的测试。由于我们只有一个名为 Test.h 的头文件需要测试,而不是也创建一个包含所有测试的单个 .cpp 文件,让我们采取一种函数式方法,根据测试的目的来分割测试。

让我们在项目中添加一个新的.cpp文件,命名为Creation.cpp,并将迄今为止的单个测试移动到新文件中。同时,让我们思考一下我们将来如何使用测试库。

我们正在构建的并不是一个会被编译并链接到其他项目的库。它只是一个名为Test.h的单个头文件,其他项目可以包含它。它仍然是一个库,只是它会在其他项目旁边编译。

我们甚至可以开始以这种方式处理我们现在的测试。在项目结构中,我们目前有Test.hmain.cppmain.cpp文件类似于旨在测试Test.h包含文件的测试项目。让我们重新组织项目结构,使得main.cpp和新的Creation.cpp文件都在一个名为tests的文件夹中。这些将形成测试可执行文件的基础,该可执行文件将执行所有测试以测试Test.h。换句话说,我们正在将我们拥有的控制台项目转变为一个测试项目,该测试项目旨在测试测试库。测试库不是一个独立的项目,因为它只是一个作为其他项目一部分被包含的单个头文件。

在以后的项目中,你也可以做同样的事情。你将有一个包含所有源文件的主要项目。你还将有一个名为tests的子文件夹中的另一个测试项目,其中包含自己的main.cpp和所有测试文件。你的测试项目将包含测试库中的Test.h,但它不会尝试像我们现在这样测试测试库。它将专注于在主要项目文件夹中测试自己的项目。一旦我们将测试库调整到适合使用以创建不同项目的状态,你将看到这一切是如何工作的。在第二部分,我们将创建一个日志库,名为Logging Library。日志库将有一个名为tests的子文件夹,正如我刚才描述的那样。

回到我们现在的情况,让我们重新组织测试库的整体项目结构。你可以创建一个tests文件夹,并将main.cpp移动到其中。确保将新的Creation.cpp文件放入tests文件夹。项目结构应该看起来像这样:

MereTDD project root folder
    Test.h
    tests folder
        main.cpp
        Creation.cpp

main.cpp文件可以通过移除测试并仅保留main来简化如下:

#include "../Test.h"
int main ()
{
    MereTDD::runTests();
    return 0;
}

现在,新的Creation.cpp文件只包含迄今为止的单个测试,如下所示:

#include "../Test.h"
#include <iostream>
TEST
{
    std::cout << mName << std::endl;
}

然而,现在以这种方式构建项目会导致链接错误,因为我们同时在main.cppCreation.cpp编译单元中包含Test.h。结果,我们有两个方法会产生重复的符号。为了删除重复的符号,我们需要将getTestsrunTests都声明为内联,如下所示:

inline std::vector<TestInterface *> & getTests ()
{
    static std::vector<TestInterface *> tests;
    return tests;
}
inline void runTests ()
{
    for (auto * test: getTests())
    {
        test->run();
    }
}

现在,一切又重新构建并运行,我们得到了之前相同的结果。输出显示了迄今为止的单个测试名称:

testCanBeCreated
Program ended with exit code: 0

输出与之前保持不变。我们没有添加更多测试或更改当前测试的功能。我们改变了测试的注册和运行方式,并对项目结构进行了重组。

摘要

本章介绍了测试库,它由一个名为 Test.h 的单个头文件组成。它还向我们展示了如何创建一个测试项目,这是一个控制台应用程序,将用于测试测试库。

我们已经看到,它如何从简单的函数发展成为一个知道如何注册和运行测试的测试库。它还没有准备好。在测试库可以在 TDD 过程中使用来帮助您设计和测试自己的项目之前,我们还有很长的路要走。

通过观察测试库的发展,您将了解如何在您的项目中使用它。在下一章中,您将了解添加多个测试的挑战。我们至今只有一个测试是有原因的。下一章将涵盖启用多个测试和报告测试结果的内容。

第二章:测试结果

到目前为止,我们有一个只能有一个测试的测试库。在本章中,当尝试添加另一个测试时,您将看到会发生什么,您将看到如何增强测试库以支持多个测试。我们将需要使用 C++的一个古老且很少使用的功能,这个功能实际上源于其早期的 C 语言根源,以支持多个测试。

一旦我们有了多个测试,我们需要一种查看结果的方法。这将让您一眼就能看出是否一切顺利。最后,我们将修复结果输出,使其不再假设std::cout

本章我们将涵盖以下主要主题:

  • 基于异常报告单个测试结果

  • 增强测试库以支持多个测试

  • 总结测试结果,以便清楚地看到哪些失败和哪些通过

  • 将测试结果重定向,以便输出可以流向任何流。

技术要求

本章中所有代码均使用标准 C++,它基于任何现代 C++ 17 或更高版本的编译器和标准库构建。代码基于上一章并继续发展。

您可以在以下 GitHub 仓库找到本章所有代码:github.com/PacktPublishing/Test-Driven-Development-with-CPP

报告单个测试结果

到目前为止,我们的单个测试在运行时只是打印其硬编码的名称。早期有一些想法,我们可能需要一个除了测试名称之外的结果。这实际上是一个向代码中添加不必要或不使用的功能的良好例子。好吧,这是一个小的例子,因为我们需要一些东西来跟踪测试是否通过或失败,但它仍然是一个超越自己的好例子,因为我们实际上从未使用过mResult数据成员。我们现在将用一种更好的方式来跟踪测试的运行结果。

我们假设测试成功,除非发生某些导致其失败的情况。可能会发生什么?最终会有很多导致测试失败的方法。现在,我们只考虑异常。这可能是一个测试在检测到有问题时故意抛出的异常,也可能是一个意外抛出的异常。

我们不希望任何异常停止测试的运行。一个测试抛出的异常不应该成为停止运行其他测试的理由。我们仍然只有一个测试,但我们可以确保异常不会停止整个测试过程。

我们想要的是将run函数调用包裹在try块中,以便任何异常都将被视为失败,如下所示:

inline void runTests ()
{
    for (auto * test: getTests())
    {
        try
        {
            test->run();
        }
        catch (...)
        {
            test->setFailed("Unexpected exception thrown.");
        }
    }
}

当捕获到异常时,我们想要做两件事。第一是标记测试为失败。第二是设置一个消息,以便可以报告结果。问题是我们在TestInterface类上没有名为setFailed的方法。实际上,首先编写我们希望它成为的样子是很好的。

事实上,TestInterface 的想法是使其成为一组纯虚方法,就像一个接口。我们可以添加一个名为 setFailed 的新方法,但实现将需要在派生类中编写。这似乎是测试的一个基本部分,能够保存结果和消息。

因此,让我们重构设计,将 TestInterface 改造成一个更基础的类,并改名为 TestBase。我们还可以将 TEST 宏内部声明的类中的数据成员移动到 TestBase 类中:

class TestBase
{
public:
    TestBase (std::string_view name)
    : mName(name), mPassed(true)
    { }
    virtual ~TestBase () = default;
    virtual void run () = 0;
    std::string_view name () const
    {
        return mName;
    }
    bool passed () const
    {
        return mPassed;
    }
    std::string_view reason () const
    {
        return mReason;
    }
    void setFailed (std::string_view reason)
    {
        mPassed = false;
        mReason = reason;
    }
private:
    std::string mName;
    bool mPassed;
    std::string mReason;
};

使用新的 setFailed 方法后,保留 mResult 数据成员就不再有意义了。相反,有一个 mPassed 成员,以及 mName 成员;这两个都来自 TEST 宏。添加一些获取方法似乎也是一个好主意,尤其是现在还有一个 mReason 数据成员。总的来说,每个测试现在可以存储其名称,记住它是否通过,以及失败的原因(如果失败的话)。

getTests 函数中,只需要进行细微的更改来引用 TestBase 类:

inline std::vector<TestBase *> & getTests ()
{
    static std::vector<TestBase *> tests;
    return tests;
}

其余的更改简化了 TEST 宏,如下所示,以删除现在在基类中的数据成员,并从 TestBase 继承:

#define TEST \
class Test : public MereTDD::TestBase \
{ \
public: \
    Test (std::string_view name) \
    : TestBase(name) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void run () override; \
}; \
Test test("testCanBeCreated"); \
void Test::run ()

检查以确保一切构建并再次运行,这表明我们又回到了一个运行程序,其结果与之前相同。你将看到在重构时经常使用这种技术。在重构时,最好将任何功能更改保持在最低限度,并主要关注恢复到之前的行为。

现在,我们可以进行一些将确实影响可观察行为的更改。我们想要报告测试运行时发生的事情。目前,我们将输出发送到 std::cout。我们将在本章的后面部分更改这一点,以避免假设输出目标。第一个更改是在 Test.h 中包含 iostream

#define MERETDD_TEST_H
#include <iostream>
#include <string_view>
#include <vector>

然后,将 runTests 函数修改为报告正在运行的测试进度,如下所示:

inline void runTests ()
{
    for (auto * test: getTests())
    {
        std::cout << "---------------\n"
            << test->name()
            << std::endl;
        try
        {
            test->run();
        }
        catch (...)
        {
            test->setFailed("Unexpected exception thrown.");
        }
        if (test->passed())
        {
            std::cout << "Passed"
                << std::endl;
        }
        else
        {
            std::cout << "Failed\n"
                << test->reason()
                << std::endl;
        }
    }
}

原始的 try/catch 代码保持不变。我们只是打印一些破折号作为分隔符和测试的名称。立即将这一行输出到输出流中可能是一个好主意。如果之后发生某些事情,至少测试的名称将被记录。测试运行后,检查测试是否通过,并显示适当的消息。

我们还将修改 Creation.cpp 中的测试,使其抛出异常以确保我们得到失败的结果。我们不再需要包含 iostream,因为这通常不是一个好主意,从测试本身显示任何内容。如果你想显示测试的输出,可以这样做,但测试本身中的任何输出往往会弄乱测试结果的报告。当我有时需要从测试内部显示输出时,这通常是临时的。

下面是修改后抛出整数的测试:

#include "../Test.h"
TEST
{
    throw 1;
}

通常,你会编写抛出除简单int值之外内容的代码,但在这个阶段,我们只想展示当确实抛出某些内容时会发生什么。

现在构建和运行它显示了预期的失败,因为出现了意外的异常:

---------------
testCanBeCreated
Failed
Unexpected exception thrown.
Program ended with exit code: 0

我们可以从测试中移除throw语句,使主体完全为空,这样测试现在就会通过了:

---------------
testCanBeCreated
Passed
Program ended with exit code: 0

我们不希望为不同的场景不断修改测试。是时候添加对多个测试的支持了。

增强测试声明以支持多个测试

虽然单个测试可以工作,但尝试添加另一个测试却无法构建。这就是我在Creation.cpp中尝试添加另一个测试时所做的。其中一个测试是空的,第二个测试抛出一个整数值。这是我们刚刚试图处理的两种情况:

#include "../Test.h"
TEST
{
}
TEST
{
    throw 1;
}

失败是由于Test类被声明了两次,以及run方法。每次使用TEST宏时,它都会声明一个新的全局Test类实例。每个实例都称为test。我们看不到这些类或实例在代码中,因为它们被TEST宏隐藏了。

我们需要修改TEST宏,使其能够生成唯一的类和实例名称。同时,我们也要修复测试本身的名称。我们不希望所有测试都使用名称"testCanBeCreated",并且由于名称需要来自测试声明,我们还需要修改TEST宏以接受一个字符串。以下是新的Creation.cpp文件应该看起来像这样:

#include "../Test.h"
TEST("Test can be created")
{
}
TEST("Test with throw can be created")
{
    throw 1;
}

这让我们可以为每个测试赋予句子名称,而不是像处理单个单词的函数名称那样对待名称。我们仍然需要修改TEST宏,但最好先从预期的使用开始,然后再让它工作。

为了生成唯一的类和实例名称,我们本可以直接要求程序员提供一些独特的东西,但类的类型名称和该类的实例名称实际上是一些编写测试的程序员不需要关心的细节。要求提供唯一的名称只会使这些细节变得可见。我们可以改用一个基本名称,并在其中添加测试声明的行号,以使类和实例名称都变得唯一。

宏有获取宏使用源代码文件行号的能力。我们只需要通过在生成的类和实例名称后附加这个行号来修改它们。

如果这很容易就好了。

所有宏都由预处理器处理。实际上,这比那要复杂一些,但以预处理器为思考方式是一种很好的简化。预处理器知道如何进行简单的文本替换和操作。编译器从未看到使用宏编写的原始代码。编译器看到的只是预处理器处理后的最终结果。

我们需要在 Test.h 中声明两组宏。一组将生成一个唯一的类名,例如如果第 7 行使用了 TEST 宏,则生成 Test7 这样的类名。另一组宏将生成一个唯一的实例名,例如 test7

我们需要一组宏,因为从行号到像 Test7 这样的连接结果需要多个步骤。如果你第一次看到宏以这种方式使用,发现它们令人困惑是正常的。宏使用简单的文本替换规则,起初可能看起来像是额外的劳动。从行号到唯一名称需要多个文本替换步骤,这些步骤并不明显。宏看起来是这样的:

#define MERETDD_CLASS_FINAL( line ) Test ## line
#define MERETDD_CLASS_RELAY( line ) MERETDD_CLASS_FINAL( line )
#define MERETDD_CLASS MERETDD_CLASS_RELAY( __LINE__ )
#define MERETDD_INSTANCE_FINAL( line ) test ## line
#define MERETDD_INSTANCE_RELAY( line ) MERETDD_INSTANCE_FINAL( line )
#define MERETDD_INSTANCE MERETDD_INSTANCE_RELAY( __LINE__ )

每组需要三个宏。每组中要使用的宏是最后一个,即 MERETDD_CLASSMERETDD_INSTANCE。这些都将被 relay 宏替换,使用 __LINE__ 的值。relay 宏将看到实际的行号而不是 __LINE__,然后 relay 宏将被替换为最终的宏和它所给的行号。最终的宏将使用 ## 操作符来进行连接。我确实警告过,如果这很容易那就好了。我确信这是许多程序员避免使用宏的原因之一。至少你已经通过了这本书中最难使用的宏。

最终结果将是,例如,类名为 Test7,实例名为 test7。这两组宏之间唯一的真正区别是,类名使用大写 T 表示 Test,而实例名使用小写 t 表示 test

需要将类和实例宏添加到 Test.h 中,位于需要使用它们的 TEST 宏定义之上。所有这些工作都是因为,尽管 TEST 宏看起来像使用了多行源代码,但请记住,每一行都是以反斜杠结尾的。这导致所有内容最终都位于单行代码中。这样,每次使用 TEST 宏时,所有行号都将相同,下一次使用时行号将不同。

新的 TEST 宏看起来是这样的:

#define TEST( testName ) \
class MERETDD_CLASS : public MereTDD::TestBase \
{ \
public: \
    MERETDD_CLASS (std::string_view name) \
    : TestBase(name) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void run () override; \
}; \
MERETDD_CLASS MERETDD_INSTANCE(testName); \
void MERETDD_CLASS::run ()

MERETDD_CLASS 宏用于声明类名,声明构造函数,声明全局实例的类型,并将 run 方法声明的作用域限定在类中。这四个宏都将使用相同的行号,因为每个宏的末尾都有反斜杠。

MERETDD_INSTANCE 宏仅使用一次来声明全局实例的名称。它也将使用与类名相同的行号。

构建项目并现在运行显示,第一个测试通过,因为它实际上并没有做任何事情,而第二个测试失败,因为它抛出了以下错误:

---------------
Test can be created
Passed
---------------
Test with throw can be created
Failed
Unexpected exception thrown.
Program ended with exit code: 0

输出结束得有些突然,现在是时候修复这个问题了。我们将添加一个总结。

总结结果

总结可以从将要运行的测试数量开始。我曾考虑为每个测试添加一个运行计数,但最终决定不这样做,因为当前的测试没有特定的顺序。我的意思不是每次运行测试应用程序时都会以不同的顺序运行,但如果代码更改并且项目重新构建,它们可能会重新排序。这是因为创建最终应用程序时,链接器在多个.cpp编译单元之间没有固定的顺序。当然,我们需要将测试分散在多个文件中,以便看到重新排序,而现在,所有测试都在Creation.cpp中。

重点是测试根据全局实例的初始化方式注册自己。在单个.cpp源文件中,有一个定义的顺序,但在多个文件之间没有保证的顺序。正因为如此,我决定不在每个测试结果旁边包含一个数字。

我们将跟踪通过和失败的测试数量,并在运行所有测试的for循环结束时显示总结。

作为额外的好处,我们还可以将runTests函数更改为返回失败的测试数量。这将允许main函数也返回失败计数,以便脚本可以测试此值以查看测试是否通过或失败了多少。应用程序退出代码为零表示没有失败。任何非零值都表示失败的运行,并将指示失败的测试数量。

这里是main.cpp中的简单更改,以返回失败计数:

int main ()
{
    return MereTDD::runTests();
}

然后,这是带有总结更改的新runTests函数。更改分为三个部分。所有这些都是一个函数。只有描述被分为三个部分。第一部分只是显示将要运行的测试数量:

inline int runTests ()
{
    std::cout << "Running "
        << getTests().size()
        << " tests\n";

在第二部分中,我们需要跟踪通过和失败的测试数量,如下所示:

    int numPassed = 0;
    int numFailed = 0;
    for (auto * test: getTests())
    {
        std::cout << "---------------\n"
            << test->name()
            << std::endl;
        try
        {
            test->run();
        }
        catch (...)
        {
            test->setFailed("Unexpected exception thrown.");
        }
        if (test->passed())
        {
            ++numPassed;
            std::cout << "Passed"
                << std::endl;
        }
        else
        {
            ++numFailed;
            std::cout << "Failed\n"
                << test->reason()
                << std::endl;
        }
    }

在第三部分中,在遍历所有测试并计算通过和失败的测试数量之后,我们显示一个带有计数的总结,如下所示:

    std::cout << "---------------\n";
    if (numFailed == 0)
    {
        std::cout << "All tests passed."
            << std::endl;
    }
    else
    {
        std::cout << "Tests passed: " << numPassed
            << "\nTests failed: " << numFailed
            << std::endl;
    }
    return numFailed;
}

现在运行项目会显示初始计数、单个测试结果和最终总结,你还可以看到由于测试失败,应用程序退出代码是1

Running 2 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Failed
Unexpected exception thrown.
---------------
Tests passed: 1
Tests failed: 1
Program ended with exit code: 1

显示退出代码的最后一行实际上不是测试应用程序的一部分。通常在运行应用程序时不会显示它。它是编写此代码的开发环境的一部分。如果你从脚本(如 Python)中运行测试应用程序作为自动化构建脚本的一部分,你通常会对退出代码感兴趣。

我们还有一项清理工作要做,与结果有关。你看,现在,所有内容都发送到std::cout,这个假设应该得到修复,以便结果可以发送到任何输出流。下一节将完成此清理。

重定向输出结果

这是一个简单的编辑,不会对到目前为止的应用程序造成任何实际变化。目前,runTests 函数在显示结果时直接使用 std::cout。我们将改变这一点,让 main 函数将 std::cout 作为参数传递给 runTests。实际上不会有任何变化,因为我们仍然会使用 std::cout 来显示结果,但这是一个更好的设计,因为它允许测试应用程序决定将结果发送到何处,而不是测试库。

我所说的测试库是指 Test.h 文件。这是其他应用程序包含以创建和运行测试的文件。在我们目前的项目中,它有点不同,因为我们正在编写测试来测试库本身。因此,整个应用程序就是 Test.h 文件和包含测试应用程序的 tests 文件夹。

我们首先需要将 main.cpp 修改为包含 iostream,然后将 std::cout 传递给 runTests,如下所示:

#include "../Test.h"
#include <iostream>
int main ()
{
    return MereTDD::runTests(std::cout);
}

然后,我们不再需要在 Test.h 中包含 iostream,因为它实际上不需要任何输入,也不需要直接引用 std::cout。它只需要包含 ostream 以支持输出流。这可以是标准输出、一个文件或任何其他流:

#ifndef MERETDD_TEST_H
#define MERETDD_TEST_H
#include <ostream>
#include <string_view>
#include <vector>

大多数更改都是将 std::cout 替换为一个新的参数,称为 output,就像在 runTests 函数中这样:

inline int runTests (std::ostream & output)
{
    output << "Running "
        << getTests().size()
        << " tests\n";
    int numPassed = 0;
    int numFailed = 0;
    for (auto * test: getTests())
    {
        output << "---------------\n"
            << test->name()
            << std::endl;

之前代码中并没有显示所有的更改。你所需要做的就是将所有使用 std::cout 的地方替换为 output

这是一个简单的更改,根本不影响应用程序的输出。实际上,做出这样的独立更改是好事,这样就可以将新结果与之前的结果进行比较,以确保没有发生意外变化。

摘要

本章介绍了宏及其根据行号生成代码的能力,作为启用多个测试的一种方式。每个测试都是一个具有自己唯一命名全局对象实例的类。

一旦支持了多个测试,你就看到了如何跟踪和报告每个测试的结果。

下一章将使用本章中的构建失败来展示 TDD 流程的第一步。我们已经遵循了这些流程步骤,但没有特别提及。你将在下一章中了解更多关于 TDD 流程的内容,以及测试库到目前为止是如何开发的,随着你理解这些原因,这些内容应该会变得更加有意义。

第三章:TDD 流程

前两章通过展示涉及步骤向您介绍了 TDD 流程。您在声明多个测试时看到了构建失败。您看到了当我们提前编写尚未需要的代码时可能发生的情况。这是一个带有测试结果的小例子,但它仍然展示了有时代码在没有测试支持的情况下就滑入项目的容易性。您还看到了代码从简单或部分实现开始,先使其工作,然后进行增强。

我们在本章中将涵盖以下主题:

  • 构建失败为何先出现,以及应将其视为流程的一部分

  • 为什么你应该只编写足够通过测试的代码

  • 如何增强测试并获得另一次通过

本章将首先向您介绍 TDD 流程。要获取更详细的代码演示,请参阅第十章深入探讨 TDD 流程

现在,是时候更刻意地学习 TDD 流程了。

技术要求

本章中所有代码都使用标准 C++,它基于任何现代 C++ 17 或更高版本编译器和标准库构建。代码基于上一章并继续发展。

您可以在以下 GitHub 仓库找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

构建失败先出现

在上一章中,您看到了要使多个测试运行起来的第一步是编写多个测试。这导致了构建失败。在编程时,编写一开始就无法构建的代码是很常见的。这些通常被认为是需要立即修复的错误或错误。随着时间的推移,大多数开发者学会了预测构建错误并避免它们。

在遵循 TDD 的过程中,我想鼓励你们停止避免构建错误,因为避免构建错误的方法通常意味着你在尝试使用新功能或更新后的代码之前,先工作于启用新功能或修改代码。这意味着你在关注细节的同时进行更改,很容易忽略更大的问题,例如使用新功能或更新代码的难易程度。

相反,首先以你认为它应该被使用的方式编写代码。这就是测试所做的那样。我在上一章中向您展示了添加另一个测试的最终结果应该看起来像这样:

#include "../Test.h"
TEST
{
}
TEST
{
    throw 1;
}

由于错误,项目被构建但未完成。这让我们知道需要修复什么。但在进行更改之前,我展示了我们真正希望测试看起来像什么:

#include "../Test.h"
TEST("Test can be created")
{
}
TEST("Test with throw can be created")
{
    throw 1;
}

只有当我们对测试应该是什么样子有一个清晰的想法后,才会对测试进行修改。如果我没有采取这种做法,可能就会找到其他方法来命名测试。甚至可能有效。但使用起来会那么方便吗?我们能否像代码所示那样简单地声明第二个TEST,并立即为每个测试命名?我不知道。

但我知道,有很多次我没有遵循这个建议,结果得到了一个我不喜欢的解决方案。我不得不回去重新做工作,直到我对结果满意为止。如果一开始我就从想要的结果开始,那么我就会确保编写直接导致那个结果的代码。

所有这些实际上只是关注点的转移。与其深入到你正在编写的代码的详细设计,不如退一步,首先编写测试代码来使用你打算制作的内容。

换句话说,让测试驱动设计。这是 TDD 的本质。

你写的代码还不能构建,因为它依赖于其他不存在的代码,但没关系,因为这为你指明了方向,你会对此感到高兴。

从某种意义上说,从用户的角度编写代码为你设定了一个目标,在你甚至开始之前就使这个目标变得真实。不要满足于对想要的东西有一个模糊的想法。先花时间编写你希望它如何使用的代码,构建项目,然后努力修复构建错误。

知道你的项目无法构建时,真的有必要尝试构建它吗?这是一个我有时会采取的捷径,尤其是在构建需要很长时间或构建失败很明显时。我的意思是,如果我现在调用一个还不存在的函数,我通常会先编写这个函数,而不进行构建。我知道它将无法构建,并且我知道需要做什么来修复它。

但有时这种捷径可能会导致问题,比如在处理重载方法或模板方法时。你可能会编写代码来使用一个尚未存在的新的重载版本,并认为代码将无法构建,而实际上编译器会选择现有的某个重载版本来执行调用。模板也是这样。

你可以在第五章中找到一个很好的例子,即预期的构建失败实际上构建了,没有任何警告或错误,添加更多确认类型。结果并不是想要的,而先进行构建允许立即发现问题。

重点是构建你的项目会让你了解这些情况。如果你预期构建会失败,但编译器仍然能够编译,那么你就知道编译器找到了一种可能你没有预料到的方法来使代码工作。这可能会带来宝贵的见解。因为当你添加预期的新的重载时,现有的代码可能会开始调用你的新方法。总是最好意识到这种情况,而不是被难以找到的 bug 所惊吓。

当你还在努力使测试构建时,你不需要担心通过。事实上,如果最初让测试失败,这会更容易。专注于预期的用法,而不是获得通过测试。

一旦你的代码构建成功,你应该实现多少?这是下一节的主题。主要思想是尽可能少做。

只做通过测试所必需的。

在编写代码时,很容易想到一个方法可能被使用的所有可能性,例如,并立即编写代码来处理每种可能性。随着经验的积累,这会变得更容易,通常被认为是一种编写健壮代码的好方法,不会忘记处理不同的用例或错误条件。

我敦促你减少一次性编写所有这些内容的热情。相反,只做通过测试所必需的。然后,当你想到其他用例时,为每个用例编写一个测试,在扩展你的代码来处理它们之前。同样适用于错误情况。当你想到应该添加的一些新错误处理时,在代码中处理之前,先编写一个会导致该错误条件出现的测试。

为了了解这是如何完成的,让我们扩展测试库以允许预期异常。我们目前有两个测试用例:

#include "../Test.h"
TEST("Test can be created")
{
}
TEST("Test with throw can be created")
{
    throw 1;
}

第一个确保可以创建一个测试。它什么都不做并通过。第二个测试抛出一个异常。它实际上只是抛出一个简单的整数值1。这导致测试失败。看到你的一个或多个测试失败可能会让你感到泄气。但记住,我们刚刚使测试构建成功,这是你应该感到自豪的成就。

当我们在上一章最初添加第二个测试时,目标是确保可以添加多个测试。我们抛出一个整数是为了确保任何异常都会被视为失败。我们当时还没有准备好完全处理抛出的异常。这正是我们现在要做的。

我们将把现有的抛出异常的代码转换成预期的异常,但我们将遵循这里给出的建议,只做绝对最小的工作来使它工作。这意味着我们不会立即跳入一个尝试抛出多个不同异常的解决方案,我们也不会处理我们认为应该抛出异常但未抛出的情况。

由于我们正在编写测试库本身,我们的关注点有时会集中在测试本身上。在许多方面,测试变得与你要工作的任何特定项目代码相似。因此,虽然现在我们需要小心不要一次性添加大量测试,但稍后你将需要小心不要一次性添加大量尚未测试的额外代码。一旦我们将测试库发展到更完整的版本并开始使用它来创建日志库,你就会看到这种转变。到那时,这些指导原则将适用于日志库,我们希望避免在没有首先为这些场景添加测试的情况下添加处理不同日志场景的额外逻辑。

从最终用途出发,我们需要考虑当存在预期异常时,TEST 宏的使用应该是什么样子。我们需要传达的主要信息是我们期望抛出的异常类型。

只需要一种类型的异常。即使测试中的某些代码抛出多个异常类型,我们也不希望在每次测试中列出超过一个异常类型。这是因为,虽然代码检查不同的错误条件并为每个错误抛出不同的异常类型是可以接受的,但每个测试本身应该只测试这些错误条件中的一个。

如果你有一个有时会抛出不同异常的方法,那么你应该为导致每个异常的条件编写一个测试。每个测试都应该具体,并且始终导致单个异常或没有任何异常。如果一个测试期望抛出异常,那么为了使测试被认为是通过的,该异常应该始终被抛出。

在本章的后面部分,我们将讨论一个更复杂的情况,即预期抛出异常但没有捕获到。现在,我们只想做必要的操作。以下是新用法的外观:

TEST_EX("Test with throw can be created", int)
{
    throw 1;
}

你首先会注意到,我们需要一个新的宏来传递预期抛出的异常类型。我将其命名为 TEST_EX,代表测试异常。在测试名称之后,有一个新的宏参数用于指定预期抛出的异常类型。在这种情况下,它是一个 int,因为代码抛出了 1

我们为什么需要一个新宏?

因为宏并不是真正的函数。它们只是进行简单的文本替换。我们希望能够区分一个不期望抛出任何异常的测试与一个期望抛出异常的测试。宏没有像方法或函数那样重载的能力,每个不同版本都使用不同的参数声明。一个宏需要根据特定的参数数量来编写。

当一个测试不期望抛出任何异常时,传递一个占位符值给异常类型是没有意义的。最好有一个只接受名称的宏,表示不期望任何异常,另一个宏接受名称和异常类型。

这是一个设计需要妥协的真实例子。理想情况下,我们不需要一个新的宏。我们在这里尽我们所能利用语言提供的内容。宏是一种老技术,有自己的规则。

回到 TDD(测试驱动开发)流程,你会发现我们再次以最终用途为出发点。这个解决方案是否可接受?它目前还不存在。但如果它存在,会感觉自然吗?我认为会的。

现在尝试构建并没有真正的意义。这是一个我们会走捷径并跳过实际构建的过程。实际上,在我的编辑器中,int类型已经被标记为错误。

它抱怨我们错误地使用了关键字,这对你来说可能看起来也很奇怪。你不能简单地将类型(无论它们是否是关键字)作为方法参数传递。记住,尽管宏不是真正的方法,一旦宏被完全展开,编译器将永远不会看到这种奇怪的int使用方式。你可以将类型作为模板参数传递。但是,宏也不支持模板参数。

现在我们有了预期的使用方式,下一步是考虑实现这种使用的解决方案。我们不希望测试作者必须为期望的异常编写try/catch块。这正是测试库应该做的。这意味着我们需要在Test类中添加一个新的方法,该方法确实包含try/catch块。这个方法可以捕获期望的异常并暂时忽略它。我们忽略它是因为我们期望异常,这意味着如果我们捕获它,那么测试应该通过。如果我们让期望的异常在测试之外继续,那么runTests函数将捕获它并报告由于意外异常而失败的错误。

我们希望将捕获所有异常的操作放在runTests函数中,因为这是我们检测意外异常的方式。对于意外异常,我们不知道要捕获什么类型,因为我们希望准备好捕获任何东西。

在这里,我们知道期望哪种类型的异常,因为它是通过TEST_EX宏提供的。我们可以在Test类的新方法中捕获期望的异常。让我们把这个新方法叫做runExrunEx方法需要做的只是查找期望的异常并忽略它。如果测试抛出了其他东西,runEx将不会捕获它。但runTests函数一定会捕获它。

让我们看看一些代码来更好地理解。这是Test.h中的TEST_EX宏:

#define TEST_EX( testName, exceptionType ) \
class MERETDD_CLASS : public MereTDD::TestBase \
{ \
public: \
    MERETDD_CLASS (std::string_view name) \
    : TestBase(name) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void runEx () override \
    { \
        try \
        { \
            run(); \
        } \
        catch (exceptionType const &) \
        { \
        } \
    } \
    void run () override; \
}; \
MERETDD_CLASS MERETDD_INSTANCE(testName); \
void MERETDD_CLASS::run ()

你可以看到,runEx 所做的只是在一个捕获了指定 exceptionTypetry/catch 块中调用原始的 run 方法。在我们的特定情况下,我们将捕获一个整数并忽略它。这仅仅是将 run 方法包裹在一个 try/catch 块中,这样测试作者就不必这样做。

runEx 方法也是一个 虚拟覆盖。这是因为 runTests 函数需要调用 runEx 而不是直接调用 run。只有这样,预期的异常才能被捕获。我们不希望 runTests 有时为期望异常的测试调用 runEx,而为没有期望异常的测试调用 run。如果 runTests 总是调用 runEx 会更好。

这意味着我们需要一个默认的 runEx 实现来调用 run 而不带 try/catch 块。我们可以在 TestBase 类中这样做,因为这个类无论如何都需要声明虚拟的 runEx 方法。在 TestBase 中,runrunEx 方法看起来是这样的:

    virtual void runEx ()
    {
        run();
    }
    virtual void run () = 0;

期望异常的 TEST_EX 宏将覆盖 runEx 以捕获异常,而不期望异常的 TEST 宏将使用基类 runEx 的实现,它直接调用 run

现在,我们需要修改 runTests 函数,使其调用 runEx 而不是 run,如下所示:

inline int runTests (std::ostream & output)
{
    output << "Running "
        << getTests().size()
        << " tests\n";
    int numPassed = 0;
    int numFailed = 0;
    for (auto * test: getTests())
    {
        output << "---------------\n"
            << test->name()
            << std::endl;
        try
        {
            test->runEx();
        }
        catch (...)
        {
            test->setFailed("Unexpected exception thrown.");
        }

这里只显示了 runTests 函数的前半部分。函数的其余部分保持不变。实际上只需要更新 try 块中现在调用 runEx 的那行代码。

现在,我们可以构建项目并运行它来查看测试的表现。输出如下:

Running 2 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Passed
---------------
All tests passed.
Program ended with exit code: 0

第二个测试之前曾经失败,但现在它通过了,因为异常是预期的。我们还遵循了本节的指导原则,即只做通过测试所需的最少工作。TDD 流程的下一步是增强测试并获得另一个通过。

增强测试并获得另一个通过

如果一个期望异常的测试没有看到异常会发生什么?这应该是一个失败,我们将在下一部分处理它。这种情况有点不同,因为下一个 通过 实际上将会是一个 失败

当你编写测试并遵循先做最少的工作以获得第一个通过结果,然后增强测试以获得另一个通过时,你将专注于通过。这是好的,因为我们希望所有测试最终都能通过。

任何失败几乎总是失败。在测试中拥有 预期失败 通常没有意义。我们接下来要做的事情有点不同寻常,这是因为我们仍在开发测试库本身。我们需要确保预期的缺失异常没有发生时能够被捕获为失败的测试。然后我们希望将这个失败的测试视为通过,因为我们正在测试测试库能够捕获这些失败的能力。

目前,我们在测试库中有一个漏洞,因为添加了一个预期抛出 int 但从未实际抛出 int 的第三个测试被视为通过测试。换句话说,这个集合中的所有测试都通过了:

#include "../Test.h"
TEST("Test can be created")
{
}
TEST_EX("Test with throw can be created", int)
{
    throw 1;
}
TEST_EX("Test that never throws can be created", int)
{
}

构建这个没有问题,运行它显示所有三个测试都通过:

Running 3 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Passed
---------------
All tests passed.
Program ended with exit code: 0

这不是我们想要的。第三个测试应该失败,因为它预期抛出一个 int,但并没有发生。但这也违反了所有测试都应该通过的目标。没有办法有一个预期的失败。当然,我们可能能够将这个概念添加到测试库中,但这会增加额外的复杂性。

如果我们添加测试失败但仍然被视为通过的能力,那么如果测试由于某些意外原因失败会发生什么?很容易编写一个坏测试,由于多个原因失败,但实际上报告为通过,因为失败是预期的。

在写这篇文档的时候,我最初决定不添加预期失败的能力。我的理由是所有测试都应该通过。但这样我们就陷入了困境,因为否则我们如何验证测试库本身是否能够正确地检测到缺失的预期异常?

我们需要关闭第三次测试暴露的漏洞。

对于这个困境没有好的答案。所以,我将做的是让这个新的测试失败,然后添加将失败视为成功的能力。我不喜欢其他选择,比如在代码中留下测试但将其注释掉,这样它实际上就不会运行,或者完全删除第三个测试。

最终说服我添加对成功失败测试支持的想法是,一切都应该被测试,特别是像确保总是抛出预期异常这样的大型功能。你可能不需要使用标记测试为预期失败的能力,但如果你需要,你将能够做同样的事情。我们处于一个独特的情况,因为我们需要测试关于测试库本身的一些东西。

好吧,让我们让新的测试失败。为此需要的最小代码量是在捕获到预期异常时返回。如果没有捕获到异常,那么我们抛出其他东西。需要更新的代码是runEx方法的TEST_EX宏重写,如下所示:

    void runEx () override \
    { \
        try \
        { \
            run(); \
        } \
        catch (exceptionType const &) \
        { \
            return; \
        } \
        throw 1; \
    } \

宏的其他部分没有变化,所以这里只展示了runEx重写。当捕获到预期异常时,我们返回,这将导致测试通过。在try/catch块之后,我们抛出其他东西,这将导致测试失败。

如果你觉得看到简单的 int 值被抛出很奇怪,请记住,我们的目标是做到这一点。你永远不会想留下这样的代码,我们将在下一版本中修复这个问题。

这很有效,也很好,因为它是我们想要做到的最低限度的需求,但结果看起来很奇怪,具有误导性。以下是测试结果输出:

Running 3 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Failed
Unexpected exception thrown.
---------------
Tests passed: 2
Tests failed: 1
Program ended with exit code: 1

你可以看到我们遇到了失败,但消息显示为Unexpected exception thrown.。这个消息几乎是我们不希望看到的。我们希望它显示为“预期的异常没有被抛出”。在我们继续将其转换为预期失败之前,让我们先修复这个问题。

首先,我们需要一种方法让runTests函数能够区分意外异常和缺失异常。目前,它只是捕获所有异常,并将任何异常都视为意外的。如果我们抛出一个特殊的异常并首先捕获它,那么这可以成为异常缺失的信号。其他被捕获的任何东西都将被视为意外的。好的,这个特殊的抛出应该是什么?

抛出的最好的东西将是测试库专门为此目的定义的东西。我们可以定义一个新的类来专门处理这个。

让我们称它为MissingException,并在MereTDD命名空间内定义它,如下所示:

class MissingException
{
public:
    MissingException (std::string_view exType)
    : mExType(exType)
    { }
    std::string_view exType () const
    {
        return mExType;
    }
private:
    std::string mExType;
};

不仅这个类会表明预期的异常没有被抛出,它还会跟踪应该抛出的异常类型。这个类型在 C++编译器理解类型的意义上,不是一个真正的类型。它将是该类型的文本表示。这实际上与设计非常吻合,因为这正是TEST_EX宏接受的,一段文本,当宏展开时,会在代码中替换为实际类型。

runEx方法的TEST_EX宏实现中,我们可以将其更改为如下所示:

    void runEx () override \
    { \
        try \
        { \
            run(); \
        } \
        catch (exceptionType const &) \
        { \
            return; \
        } \
        throw MereTDD::MissingException(#exceptionType); \
    } \

与之前抛出一个整数不同,现在的代码抛出了一个MissingException。注意它如何使用宏的另一个特性,即使用#运算符将宏参数转换为字符串字面量。通过在exceptionType前放置#,它将TEST_EX宏使用中提供的int转换为"int"字符串字面量,这样就可以用期望抛出的异常类型的名称来初始化MissingException

我们现在抛出了一个可以识别缺失异常的特殊类型,所以剩下的唯一部分就是捕获这个异常类型并处理它。这发生在runTests函数中,如下所示:

        try
        {
            test->runEx();
        }
        catch (MissingException const & ex)
        {
            std::string message = "Expected exception type ";
            message += ex.exType();
            message += " was not thrown.";
            test->setFailed(message);
        }
        catch (...)
        {
            test->setFailed("Unexpected exception thrown.");
        }

顺序很重要。我们需要首先尝试捕获MissingException,然后再捕获其他所有异常。如果我们捕获到MissingException,那么代码会更改显示的消息,让我们知道期望抛出但未抛出的异常类型。

现在运行项目会显示一个更适用于失败的更适用的消息,如下所示:

Running 3 tests
---------------
Test can be created
Passed
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Failed
Expected exception type int was not thrown.
---------------
Tests passed: 2
Tests failed: 1
Program ended with exit code: 1

这清楚地描述了测试失败的原因。我们现在需要将失败转换为通过测试,并且保留失败消息会很好。我们只需将状态从Failed更改为Expected failure。由于我们保留了失败消息,我有一个想法,可以使将失败的测试标记为通过的功能更安全。

我所说的更安全的功能是什么意思?嗯,这是我添加预期失败能力时最大的担忧之一。一旦我们将测试标记为预期失败,那么测试因其他原因失败就会变得太容易了。那些其他原因应该被视为真正的失败,因为它们不是预期的原因。换句话说,如果我们将任何失败都视为测试通过,那么如果测试因其他原因失败会怎样?这也会被视为通过,这是不好的。我们希望将失败标记为通过,但仅限于预期失败。

在这个特定情况下,如果我们只是将失败视为通过,那么如果测试本应抛出一个整数但反而抛出一个字符串会发生什么?这肯定会引起失败,我们还需要为这种情况添加一个测试用例。我们不妨现在就添加这个测试。我们不希望将抛出不同异常的行为与完全不抛出任何异常的行为同等对待。两者都是失败,但测试应该是具体的。任何其他情况都应导致合法的失败。

让我们从最终用途出发,探讨如何最好地表达新概念。我考虑过在宏中添加一个预期的失败消息,但这将需要一个新的宏。实际上,我们需要为每个现有的宏创建一个新的宏。我们需要扩展TEST宏和TEST_EX宏,添加两个新的宏,例如FAILED_TESTFAILED_TEST_EX。这看起来并不是一个好主意。如果我们相反,给TestBase类添加一个新的方法会怎样?当在新测试中使用时,它应该看起来像这样:

// This test should fail because it throws an
// unexpected exception.
TEST("Test that throws unexpectedly can be created")
{
    setExpectedFailureReason(
        "Unexpected exception thrown.");
    throw "Unexpected";
}
// This test should fail because it does not throw
// an exception that it is expecting to be thrown.
TEST_EX("Test that never throws can be created", int)
{
    setExpectedFailureReason(
        "Expected exception type int was not thrown.");
}
// This test should fail because it throws an
// exception that does not match the expected type.
TEST_EX("Test that throws wrong type can be created", int)
{
    setExpectedFailureReason(
        "Unexpected exception thrown.");
    throw "Wrong type";
}

软件设计完全是关于权衡。我们正在添加将失败测试转换为通过测试的能力。代价是额外的复杂性。用户需要知道需要在测试体内部调用setExpectedFailureReason方法来启用此功能。但好处是,我们现在可以以安全的方式测试那些在其他情况下不可能测试的事情。另一件需要考虑的事情是,这种设置预期失败的能力很可能不需要在测试库之外使用。

预期失败的原因也有些难以正确理解。很容易遗漏一些东西,比如失败原因末尾的句号。我发现获取确切原因文本的最佳方式是让测试失败,然后从摘要描述中复制原因。

到目前为止,我们无法有一个专门寻找完全意外异常的测试。现在我们可以了。当我们期望抛出异常时,我们现在可以检查与这种情况相关的两个失败情况,即当期望的类型没有被抛出时,以及当抛出其他类型时。

所有这些都比省略这些测试或注释掉它们的替代方案要好,而且我们可以做到这一切而不需要添加更多的宏。当然,测试现在还无法编译,因为我们还没有创建setExpectedFailureReason方法。所以,我们现在就添加它:

    std::string_view reason () const
    {
        return mReason;
    }
    std::string_view expectedReason () const
    {
        return mExpectedReason;
    }
    void setFailed (std::string_view reason)
    {
        mPassed = false;
        mReason = reason;
    }
    void setExpectedFailureReason (std::string_view reason)
    {
        mExpectedReason = reason;
    }
private:
    std::string mName;
    bool mPassed;
    std::string mReason;
    std::string mExpectedReason;
};

我们需要一个新成员变量来保存预期的原因,它将是一个空字符串,除非在测试体内部设置。我们需要setExpectedFailureReason方法来设置预期的失败原因,我们还需要一个expectedReason获取方法来检索预期的失败原因。

现在我们有了标记测试为特定预期失败原因的能力,让我们在runTests函数中查找预期的失败:

        if (test->passed())
        {
            ++numPassed;
            output << "Passed"
                << std::endl;
        }
        else if (not test->expectedReason().empty() &&
            test->expectedReason() == test->reason())
        {
            ++numPassed;
            output << "Expected failure\n"
                << test->reason()
                << std::endl;
        }
        else
        {
            ++numFailed;
            output << "Failed\n"
                << test->reason()
                << std::endl;
        }

你可以看到在else if块中为未通过测试添加的新测试。我们首先确保预期的原因不是空的,并且它与实际失败原因匹配。如果预期的失败原因与实际失败原因匹配,那么我们因为预期的失败而将这个测试视为通过。

现在构建项目并运行它显示所有五个测试都通过了:

Running 5 tests
---------------
Test can be created
Passed
---------------
Test that throws unexpectedly can be created
Expected failure
Unexpected exception thrown.
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Expected failure
Expected exception type int was not thrown.
---------------
Test that throws wrong type can be created
Expected failure
Unexpected exception thrown.
---------------
All tests passed.
Program ended with exit code: 0

你可以看到有三个新的预期失败的测试。所有这些都是通过测试,我们现在有了期待测试失败的能力。要明智地使用它。期待测试失败并不正常。

我们还有一个场景需要考虑。我会坦白地说,在我想到这一点之前,我休息了一个小时左右。我们需要确保测试库覆盖了我们能想到的所有内容,因为你会用它来测试你的代码。你需要有很高的信心,即测试库本身尽可能没有错误。

这是我们需要处理的案例。如果有一个测试用例因为某种原因预期会失败,但实际上通过了怎么办?目前,测试库首先检查测试是否通过,如果是的话,它甚至不会查看它是否应该失败。如果通过了,那么它就通过了。

但是,如果你费尽周折设置了预期的失败原因,而测试却通过了,那么结果应该是什么?我们目前遇到的是一个本应被视为通过但实际上通过的失败。这最终应该算作失败吗?一个人可能会因为这些事情而感到头晕。

如果我们将这视为失败,那么我们就回到了起点,有一个我们想要包含但最终会失败的测试用例。这意味着我们不得不面对测试中的失败,忽略这个场景并跳过测试,或者写一个测试然后注释掉,这样它就不会正常运行,或者找到另一种解决方案。

与失败共存不是一种选择。在使用 TDD 时,你需要让你的所有测试都达到通过状态。期待失败没有任何好处。这就是我们费尽周折允许预期失败的测试失败的全部原因。然后,我们可以将这些失败称为通过,因为它们是预期的。

跳过测试也不是一个选项。如果你决定某件事真的不是问题,不需要测试,那么就另当别论。你不想有一堆无用的测试让你的项目变得杂乱。尽管如此,这似乎是一个重要的内容,我们不想跳过。

编写一个测试然后禁用它,让它不运行,也是一个坏主意。很容易忘记测试曾经存在过。

我们需要另一个解决方案。不,这并不是在增加一个层级,在这个层级中,一个本应按预期方式失败的通过测试被当作失败处理,然后我们再以某种方式将其标记为通过。我甚至不确定如何表达这个句子,所以我会让它听起来尽可能的混乱。这条路径会导致一个永无止境的通过-失败-通过-失败-通过思考循环。太复杂了。

我能想到的最好的办法是将这种情况视为未记录的失败。这样我们可以测试这个场景,并且总是运行测试,但避免真正的失败,这会导致自动化工具因为发现失败而拒绝构建。

这里是展示上述场景的新测试。它目前没有任何问题地通过:

// This test should throw an unexpected exception
// but it doesn't. We need to somehow let the user
// know what happened. This will result in a missed failure.
TEST("Test that should throw unexpectedly can be created")
{
    setExpectedFailureReason(
        "Unexpected exception thrown.");
}

运行这个新的测试确实像这样悄无声息地通过:

Running 6 tests
---------------
Test can be created
Passed
---------------
Test that throws unexpectedly can be created
Expected failure
Unexpected exception thrown.
---------------
Test that should throw unexpectedly can be created
Passed
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Expected failure
Expected exception type int was not thrown.
---------------
Test that throws wrong type can be created
Expected failure
Unexpected exception thrown.
---------------
All tests passed.
Program ended with exit code: 0

我们需要在runTests函数中检查通过测试时预期的错误结果是否已设置,如果是,那么就增加一个新的numMissedFailed计数而不是通过计数。新的计数也应该在最后总结,但只有当它不是零时。

这里是runTests的开始部分,其中声明了新的numMissedFailed计数:

inline int runTests (std::ostream & output)
{
    output << "Running "
        << getTests().size()
        << " tests\n";
    int numPassed = 0;
    int numMissedFailed = 0;
    int numFailed = 0;

这里是runTests中检查通过测试的部分。在这里,我们需要寻找一个本应因预期失败而失败但实际通过的通过测试:

        if (test->passed())
        {
            if (not test->expectedReason().empty())
            {
                // This test passed but it was supposed
                // to have failed.
                ++numMissedFailed;
                output << "Missed expected failure\n"
                    << "Test passed but was expected to fail."
                    << std::endl;
            }
            else
            {
                ++numPassed;
                output << "Passed"
                    << std::endl;
            }
        }

这里是runTests函数的结尾,它总结了结果。现在,如果有任何未记录的测试失败,它将显示出来:

    output << "---------------\n";
    output << "Tests passed: " << numPassed
           << "\nTests failed: " << numFailed;
    if (numMissedFailed != 0)
    {
        output << "\nTests failures missed: " <<         numMissedFailed;
    }
    output << std::endl;
    return numFailed;
}

总结部分开始变得比必要的复杂。所以,现在它总是显示通过和失败的数量,如果有任何失败,只显示失败的次数。现在,对于预期会失败但最终通过的新的测试,我们会得到一个未记录的失败。

未记录的失败是否应该包含在失败计数中?我考虑过这个问题,并决定只返回所有导致这种场景的实际失败数量。记住,你几乎不可能需要编写一个你打算失败并当作通过的测试。所以,你也不应该有未记录的失败。

输出看起来像这样:

Running 6 tests
---------------
Test can be created
Passed
---------------
Test that throws unexpectedly can be created
Expected failure
Unexpected exception thrown.
---------------
Test that should throw unexpectedly can be created
Missed expected failure
Test passed but was expected to fail.
---------------
Test with throw can be created
Passed
---------------
Test that never throws can be created
Expected failure
Expected exception type int was not thrown.
---------------
Test that throws wrong type can be created
Expected failure
Unexpected exception thrown.
---------------
Tests passed: 5
Tests failed: 0
Tests failures missed: 1
Program ended with exit code: 0

我们现在应该对这部分内容很熟悉了。你具备预期能够抛出异常并依赖测试失败的能力,如果异常没有被抛出,测试库会全面测试所有可能的异常组合。

这一节也多次展示了如何继续增强测试并使它们再次通过。如果你遵循这个过程,你将能够逐步构建测试以覆盖更复杂的场景。

摘要

本章已经将我们之前遵循的步骤明确化。

你现在知道首先以你希望代码被使用的方式编写代码,而不是深入细节并从底部向上工作以避免构建失败。从顶部工作,或者从最终用户的角度来看,会更好,这样你将得到一个让你满意的解决方案,而不是一个可构建但难以使用的解决方案。你可以通过编写你希望代码被使用的测试来实现这一点。一旦你对代码的使用方式感到满意,然后构建它并查看构建错误以修复它们。让测试通过还不是目标。这种关注点的微小变化将导致更易于使用和更直观的设计。

一旦你的代码构建完成,下一步就是只做必要的操作以确保测试通过。总有可能某个更改会导致之前通过测试现在失败。这是正常的,也是只做必要操作的另一个好理由。

最后,你可以在编写代码以通过所有测试之前,增强测试或添加更多测试。

尽管测试库远未完善。目前唯一导致测试失败的方法是抛出一个未预期的异常。你可以看到,即使在更高的层面,我们也在遵循只做必要操作、使其工作,然后增强测试以添加更多功能的做法。

下一个增强是让测试程序员检查测试中的条件,以确保一切正常工作。下一章将开始这项工作。

第四章:向项目中添加测试

在本章中,我们将向测试库添加一个主要的新功能。这个新功能将允许你在测试中检查条件,以确保一切按计划进行。有时,这些检查被称为断言,有时被称为期望。无论它们被称为什么,它们都让你确认从被测试的代码中获取的值与期望相符。

对于这本书和我们在创建的测试库,我将把这些检查称为确认。每个确认将被称为确认。这样做的原因是断言已经在 C++中使用,使用相同的名称可能会造成混淆。此外,期望在其他测试库中是一个常用术语,这本身并不是避免使用相同术语的理由。我实际上喜欢期望这个术语。但期望还有一个我们不想看到的常见行为。许多其他测试库允许测试在期望失败的情况下继续进行。我真的不喜欢这种行为。一旦出现问题,我认为是时候结束那个测试了。其他测试仍然可以运行。但我们不应该继续运行一个一旦与我们的期望不符就停止的测试。

到目前为止,你可以使用测试库编写多个测试,运行它们,并查看结果。每个测试的结果要么是通过,要么是失败。你甚至可以期望某些失败,并将它们视为通过。还有一个第三种结果,可能不需要在测试库本身之外使用,那就是错过失败。你可以在前三章中了解所有这些功能。

在本章中,我们将涵盖以下主要内容:

  • 如何检测测试是通过还是失败

  • 增强测试库以支持确认

  • 是否应该测试错误情况?

我们之所以等到本章才添加确认,是有原因的。我们正在遵循对测试库本身设计的 TDD(测试驱动开发)方法。这意味着我们让测试驱动设计。这是一种敏捷的软件开发方法。我们考虑下一个最宝贵或最必要的功能或能力是什么,该功能的最终用途是什么,然后编写最少的代码来实现它,并通过添加更多功能来增强设计。

到目前为止,添加确认并没有意义。我们需要首先让基本功能工作,这样我们就可以在考虑测试内部要做什么之前创建和运行测试。也许我们可以在异常处理之前添加确认。但我选择在确认之前先处理异常。异常似乎与测试的基本声明和运行更紧密相关,因此比确认更有价值。

此外,你还会看到我们将使用异常来启用确认。这也是为什么处理异常的基本能力在确认之前的原因。

现在,我们可以将注意力转向带有确认的测试。同样,我们将做最少的努力来使确认功能化和有用。我们将在下一章继续添加更多到确认的能力。

技术要求

本章中的所有代码都使用标准 C++,它基于任何现代 C++ 17 或更高版本的编译器和标准库。代码基于前几章并继续发展。

您可以在以下 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

如何检测测试是否通过或失败

在本章中,我们将创建的测试与创建测试不同,因此它们应该有自己的文件。在编写自己的测试时,您也会希望将它们组织到多个文件中。

让我们创建一个名为 Confirm.cpp 的新文件,并将其放置在 tests 文件夹内。有了这个新文件,项目结构将如下所示:

MereTDD project root folder
    Test.h
    tests folder
        main.cpp
        Confirm.cpp
        Creation.cpp

然后,向新文件添加一个单独的测试,使其看起来像这样:

#include "../Test.h"
TEST("Test will pass without any confirms")
{
}

我们已经在 Creation.cpp 中有一个空测试,如下所示:

TEST("Test can be created")
{
}

这两个测试之间唯一的真正区别是名称。我们真的需要另一个执行相同操作但名称不同的测试吗?我可以在关于添加具有不同名称但执行相同操作代码的辩论的任何一边进行争论。有些人可能会看到这一点,认为这是纯粹的代码重复。

对我来说,区别在于 意图。是的,这两个测试现在碰巧是相同的。但谁知道将来是否会对其中一个或两个进行修改?如果发生这种情况,我们能否记得测试正在服务于多个目的?

我强烈建议您将每个测试都写成似乎它是您代码和测试旨在防止的错误之间的唯一障碍。或者,也许测试正在执行特定的用法以确保在设计更改后不会出现任何问题。只要它们测试不同的事情,有两个相同的测试是可以接受的。目标是应该独特的。

在这种情况下,原始测试只是确保可以以最基本的形式创建测试。新的测试专门确保空测试可以通过。这两个测试只是碰巧需要相同的测试方法体来实现目标。

现在我们已经在项目中添加了一个新文件和一个新测试,让我们构建并确保一切按预期工作。但它失败了。构建失败的原因如下:

ld: 5 duplicate symbols for architecture x86_64

代码编译没有问题,但项目无法链接到最终的可执行文件。我们有五个链接器错误。其中一个链接器错误如下所示:

duplicate symbol 'Test3::run()'

我只列出了一个链接器错误,因为它们都是相似的。问题是现在有两个Test3的声明。一个声明来自每个文件,Creation.cppConfirm.cpp;这是因为TEST宏根据TEST宏在源文件中出现的行号声明了一个具有唯一数字的Test类。两个文件恰好都在第 3 行使用了TEST宏,因此它们各自声明了一个名为Test3的类。

解决这个问题的方法是,在宏中声明类时使用一个未命名的命名空间。这样仍然会创建两个类,例如Test3,但每个类都将位于一个不扩展到声明它的.cpp文件之外的命名空间中。这意味着测试类可以继续基于行号,行号在每.cpp文件中都是唯一的,并且现在将不再与在另一个.cpp文件中声明在相同行号的任何其他测试冲突。

我们需要做的只是修改TESTTEST_EX宏,在每个宏中仅将未命名的命名空间添加到类声明周围。我们不需要将命名空间扩展到宏的末尾,因为宏继续声明run方法的开始。幸运的是,run方法的声明不需要在命名空间内。否则,我们不得不在run方法完全定义后,通过关闭花括号来确定如何结束命名空间。实际上,我们可以在类声明后结束命名空间。TEST宏看起来像这样:

#define TEST( testName ) \
namespace { \
class MERETDD_CLASS : public MereTDD::TestBase \
{ \
public: \
    MERETDD_CLASS (std::string_view name) \
    : TestBase(name) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName); \
void MERETDD_CLASS::run ()

TEST_EX宏也需要一个类似的未命名的命名空间,如下所示:

#define TEST_EX( testName, exceptionType ) \
namespace { \
class MERETDD_CLASS : public MereTDD::TestBase \
{ \
public: \
    MERETDD_CLASS (std::string_view name) \
    : TestBase(name) \
    { \
        MereTDD::getTests().push_back(this); \
    } \
    void runEx () override \
    { \
        try \
        { \
            run(); \
        } \
        catch (exceptionType const &) \
        { \
            return; \
        } \
        throw MereTDD::MissingException(#exceptionType); \
    } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName); \
void MERETDD_CLASS::run ()

现在项目可以再次构建,运行它将显示新的测试。根据你的链接器构建最终可执行文件的顺序,你可能发现新的测试在之前的测试之前或之后运行。以下是我运行测试项目时的一部分结果:

Running 7 tests
---------------
Test will pass without any confirms
Passed
---------------
Test can be created
Passed
---------------

其他五个测试和总结没有显示。前一章以六个测试结束,我们刚刚添加了一个,总数达到了七个测试。重要的是,新的测试运行并通过了。现在我们可以考虑确认将是什么样子。确认某件事意味着什么呢?

当运行测试时,你不仅想要验证测试是否完成,还要验证它是否正确完成。在过程中检查以确保一切按预期运行也有帮助。你可以通过比较从被测试的代码中获得的值来确保它们与你自己计算出的预期值相匹配。

假设你有一个函数,它将两个数字相加并返回一个结果。你可以用已知的值调用这个函数,并将返回的和与你自己计算出的预期和进行比较。你确认计算出的和与预期和相匹配。如果值匹配,则确认通过。但如果值不匹配,则确认失败,这应该导致测试也失败。

一个测试可以有多个确认,并且每个都会被检查以确保它们通过。一旦一个确认失败,就没有继续测试的意义,因为它已经失败了。一些 TDD 纯粹主义者会声称测试应该只有一个确认。我认为在只有一个确认和编写试图验证一切的史诗级测试之间有一个很好的折衷方案。

使用多个确认来编写测试的一种流行风格是跟踪通过确认的数量,即使确认失败,测试也会继续。这种风格有一个好处,因为开发者有时可以通过一次测试运行来解决多个问题。我们不采取这种方法,因为我认为在实践中很少能实现这种好处。有些人可能会争论这一点,但请听我说。一旦某件事被证明不符合你的预期,最可能的结果是进一步失败的连锁反应。我很少看到设计良好的测试在一次确认失败后,又 somehow 恢复通过相关的确认。如果测试表现出这种行为,那么它通常是在测试不相关的问题,应该被拆分成多个测试。我们将遵循的实践是:当一个确认失败时,测试本身就已经失败。其他测试可能进行得很好。但带有失败确认的测试已经失败了,继续查看是否测试的某个部分可能仍然正常是没有意义的。

当编写测试时,就像编写常规代码一样,避免重复是好的。换句话说,如果你发现自己通过检查其他测试中已经检查过的值来测试相同的事情,那么是时候考虑每个测试的目标了。编写一个测试,覆盖一些将被多次使用的基功能。然后,在其他使用相同功能的测试中,你可以假设它已经被测试并且工作正常,因此没有必要再次通过额外的确认来验证它。

一些代码可能会使这一切更加清晰。首先,让我们思考在没有确认的情况下如何验证预期的结果。这是一个我们无法直接编写确认将呈现的代码,因为我们还不知道我们希望它做什么的时候。需要进行一点探索。下一节将把在这里进行的探索转化为实际的确认。

让我们暂时假设我们有一个真实的 TDD 项目正在工作。我们将保持事情简单,并说我们需要一种方法来确定学校成绩是否及格。即使这个简单的例子,如果对于通过家庭作业、测验或测试有不同的及格标准,也可能变得复杂。如果是那样的话,可能涉及到整个类层次结构。我们只是需要一种简单的方法来确定 0 到 100 分的分数是否是及格分数。

现在我们有了我们的场景,一个简单的测试看起来会是什么样子?我们没有代码来支持评分要求。这只是我们想要的一般想法。因此,如果我们尝试在创建测试后立即运行,我们预期构建会失败。这就是你可以使用 TDD 来构思设计的方法。

目前,我们将这段代码放在Confirm.cpp内部。如果我们真的在为一个学校评分应用程序构建测试项目,那么可能有一个名为Grades.cpp的测试文件。因为我们只是在探索,我们将使用我们已有的测试文件,即Confirm.cpp,并创建一个这样的测试:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    if (result)
    {
        throw 1;
    }
}

第一件事是考虑用法。如果你有一个名为isPassingGrade的函数,它接受一个分数并返回一个 bool 结果,这会满足你的要求并且易于使用吗?这似乎足够简单。它将执行所需的一切来告诉我们分数是否及格,并在分数及格时返回 true,不及格时返回 false。

然后,你可以考虑如何测试这个函数。测试边界条件总是好的,所以我们可以先问一个分数为 0 是否及格。我们将及格结果赋给一个可以与预期值进行比较的变量。我们预期 0 分是不及格的,这就是为什么如果结果是 true,代码会抛出异常。这将导致测试案例因为意外的异常而失败。

我们正在正确的轨道上。这是我想让你了解的关于在过程中进行检查以确保一切运行正常的内容。我们可以在同一个测试中添加另一个检查,如下所示,以确保 100 分是及格分数:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    if (result)
    {
        throw 1;
    }
    result = isPassingGrade(100);
    if (not result)
    {
        throw 1;
    }
}

现在,你可以看到单个测试检查了两件事。首先,它确保 0 分是不及格的,然后 100 分是及格的。因为这些检查非常相关,我会把它们放在同一个测试中,并确认第一个案例应该是不及格的,第二个应该是及格的。

测试确认不过是简单地对预期值进行检查,如果预期未满足,则会抛出异常。

一些 TDD 纯粹主义者会建议你将测试分成两个独立的测试。我的建议是使用你的最佳判断。我倾向于避免绝对指导,即你应该“总是”以某种方式做某事。我认为有空间灵活处理。

让我们开始构建,以便我们可以运行它并查看结果。我们唯一需要做的是添加isPassingGrade函数。我们将函数添加到Confirm.cpp的顶部。如果这是一个你正在工作的真实项目,那么你会有一个更好的地方来放置这个函数。它不会在测试项目中;相反,它将被包含在正在测试的项目中。

Confirm.cpp内部,创建一个名为isPassingGrade的函数,如下所示:

bool isPassingGrade (int value)
{
    return true;
}

现在我们可以构建并运行项目以查看结果。我们感兴趣的测试结果会失败如下:

---------------
Test passing grades
Failed
Unexpected exception thrown.
---------------

函数显然应该失败,因为它总是返回 true,不管给出的分数是多少。但那不是我们接下来要关注的重点。如果你真的在构建和测试一个评分应用程序,你会增强设计,让测试通过,然后增强测试,并继续直到所有测试都通过。

这足以说明我所说的通过检查正在运行的测试的进度,以确保其按预期进行。现在我们有一个测试,首先检查确保 0 分是一个不及格的分数,然后检查确保 100 分是一个及格的分数。这些检查中的每一个都是一个确认。在每一个点上,我们都在检查实际结果是否与预期相符。并且我们以不同的方式确认,以适应每种条件。

在下一节中,我们将增强测试库,以修复当前解决方案的问题,并使其更容易编写确认。目前,代码在检测到问题时抛出一个整数,虽然抛出确实会导致测试失败,但它会导致一个测试结果说明失败是由意外的异常引起的。

下一节将把if语句及其标准以及异常抛出包装成一个易于使用的宏,这将处理一切,并更好地描述实际失败的地方和原因。

增强测试库以支持断言

上一节中的及格分数测试有两个确认,我们将在本节中对其进行改进。它看起来是这样的:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    if (result)
    {
        throw 1;
    }
    result = isPassingGrade(100);
    if (not result)
    {
        throw 1;
    }
}

在第一个确认中,我们想确保result是 false,因为我们知道 0 分不应该是一个及格的分数。而在第二个确认中,我们想确保这次result是 true,因为我们知道 100 分应该导致及格。

你能看出if条件需要与我们要确认的内容相反吗?这是因为当确认不符合预期值时,if块会运行。我们需要使其更容易使用,因为如果我们总是必须这样编写确认,这会导致错误。但测试代码中仍然存在更大的问题。

为什么检查失败时会抛出一个整数?这是因为我们仍在探索一个真正的确认应该是什么样子。我们现在的代码只是展示了在测试过程中进行检查的需要,以确保一切按预期进行。本节将改变我们在测试中编写确认的方式。

当一个值不符合预期时抛出一个整数,也会导致错误的测试结果描述。我们不希望测试结果说明抛出了意外的异常。

然而,我们确实想要抛出一个东西。因为一旦测试偏离了预期的路径,我们不想让测试继续进行。它已经表明测试已经失败了。在预期条件未满足时随时抛出是一个在那个点上失败测试的好方法。我们需要找出一种方法来更改测试结果描述,以便更好地告诉我们出了什么问题。

首先,让我们通过抛出一个更有意义的东西来修复测试结果。请注意,以下代码使用了硬编码的数值,例如 17 和 23。这样的数字通常被称为魔法数字,应该避免使用。我们很快就会解决这个问题,直接使用意义不明确的数字是为了向您展示有更好的方法。在Confirm.cpp中,将及格分数测试从两个确认中抛出BoolConfirmException,如下所示:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    if (result)
    {
        throw MereTDD::BoolConfirmException(false, 17);
    }
    result = isPassingGrade(100);
    if (not result)
    {
        throw MereTDD::BoolConfirmException(true, 23);
    }
}

然后,我们将在稍后创建这个类。现在,我们想按照我们打算使用它的方式来编写代码。它被称为BoolConfirmException,因为它将让我们确认一个布尔值是否符合我们的预期。构造函数参数将是预期的布尔值和行号。我使用了行号 17 和 23,因为它们是我编辑器中两个throw语句的行号。在本节稍后,我们将使用一个宏,这样宏就可以自动提供行号。通常,你想要避免在代码中硬编码任何数值,除非是简单的值,如 0、1 和可能-1。任何其他值都被称为魔法数字,因为它们的含义是混淆的。

在确认中抛出的异常将基于制作有意义的测试结果描述所需的信息。对于布尔值,预期的值和行号就足够了。其他异常将需要更多信息,将在下一章中解释。我们将有多个异常类型,但它们将是相关的。继承是表示我们将抛出的不同异常类型的好方法。所有类型的基类将被称为ConfirmException

Test.h中,在MereTDD命名空间内创建一个名为ConfirmException的新类,如下所示:

namespace MereTDD
{
class ConfirmException
{
public:
    ConfirmException () = default;
    virtual ~ConfirmException () = default;
    std::string_view reason () const
    {
        return mReason;
    }
protected:
    std::string mReason;
};

然后,紧随基异常类之后,我们可以声明派生的BoolConfirmException类,如下所示:

class BoolConfirmException : public ConfirmException
{
public:
    BoolConfirmException (bool expected, int line)
    {
        mReason =  "Confirm failed on line ";
        mReason += std::to_string(line) + "\n";
        mReason += "    Expected: ";
        mReason += expected ? "true" : "false";
    }
};

BoolConfirmException的目的是通过基类中的reason方法格式化一个有意义的描述。

接下来,我们需要在运行测试时捕获基类,并显示确认原因而不是显示有意外异常的消息。修改Test.h中的runTests方法,使其能够捕获新的异常基类,并设置适当的失败消息,如下所示:

        try
        {
            test->runEx();
        }
        catch (ConfirmException const & ex)
        {
            test->setFailed(ex.reason());
        }

确认异常已准备好。构建和运行显示以下测试结果:

---------------
Test passing grades
Failed
Confirm failed on line 17
    Expected: false
---------------

这比说出现了意外的异常要好得多。现在,我们了解到在第 17 行出现了确认失败,测试期望的值是错误的。第 17 行对应于 0 分,这是我们期望的失败分数。

让我们为确认添加一个宏,这样我们就不必手动提供行号了。并且宏可以在if条件中包含反向逻辑以及抛出适当的确认异常。以下是使用宏的测试应该看起来像什么。我们将添加宏,但只有在编写了打算使用宏的代码之后。将Confirm.cpp中的及格分数测试更改如下:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    CONFIRM_FALSE(result);
    result = isPassingGrade(100);
    CONFIRM_TRUE(result);
}

现在测试看起来真的像是在使用确认。此外,宏使得第一个确认期望result为假,而第二个确认期望result为真变得非常清楚。传递给宏的值称为实际值。只要实际值与期望值匹配,确认就通过并允许测试继续。

要定义这些宏,我们将它们放在Test.h的末尾。请注意,每个宏几乎与测试手动编写的代码完全相同:

#define CONFIRM_FALSE( actual ) \
if (actual) \
{ \
    throw MereTDD::BoolConfirmException(false, __LINE__); \
}
#define CONFIRM_TRUE( actual ) \
if (not actual) \
{ \
    throw MereTDD::BoolConfirmException(true, __LINE__); \
}

你可以看到,当确认一个期望的值为假时,if条件寻找一个真实的实际值。此外,当确认一个期望的值为真时,if条件寻找一个错误实际值。这两个宏都会抛出BoolConfirmException并使用__LINE__自动获取行号。

现在,运行测试显示几乎完全相同的结果。唯一的区别是及格分数测试失败的行号。这是因为确认宏现在每行使用一个。结果如下所示:

---------------
Test passing grades
Failed
Confirm failed on line 15
    Expected: false
---------------

现在确认的使用更加简单,它们使得测试更容易阅读和理解。我们的目标不是构建一个学校评分应用程序,所以我们将移除探索性代码。然而,在移除之前,下一节将使用及格分数测试来解释 TDD 的另一个重要方面。那就是关于错误情况应该怎么办的问题。

是否也应该测试错误情况?

是否可能达到 100%的测试代码覆盖率?这意味着什么?

让我通过继续使用我们在上一节中探索的及格分数代码来解释。这里再次是测试:

TEST("Test passing grades")
{
    bool result = isPassingGrade(0);
    CONFIRM_FALSE(result);
    result = isPassingGrade(100);
    CONFIRM_TRUE(result);
}

目前,这个测试确实覆盖了被测试函数的 100%。这意味着isPassingGrade函数中的所有代码至少被一个测试运行。我知道,isPassingGrade函数是一个简单的函数,只有一行代码,总是返回 true。它看起来像这样:

bool isPassingGrade (int value)
{
    return true;
}

对于这样一个简单的函数,只需在测试中调用它,就可以确保所有代码都被覆盖或运行。目前,这个函数不起作用,需要增强以通过两个确认。我们可以将其增强为如下所示:

bool isPassingGrade (int value)
{
    if (value < 60)
    {
        return false;
    }
    return true;
}

现在构建和运行项目可以通过测试。及格分数测试的结果如下所示:

---------------
Test passing grades
Passed
---------------

而且我们仍然为这个函数保持了 100%的代码覆盖率,因为通过成绩测试调用了函数两次,分别使用 0 和 100 的值。第一次调用使if条件为真,从而执行if块内的代码。第二次调用使if块之后的return语句执行。通过使用 0 和 100 的值调用isPassingGrade,我们确保了所有代码至少运行一次。这就是实现 100%代码覆盖率的意义。

0 和 100 这两个值都是有效的成绩,使用它们进行测试是有意义的。我们不需要测试如果我们用 1 或 99 的值调用isPassingGrade会发生什么。这是因为它们并不有趣。

边缘值几乎总是有趣的。因此,在测试中添加对 59 和 60 这两个值的几个调用是有意义的。虽然这些代表良好的调用值并确认需要添加到测试中,但它们对代码覆盖率没有任何帮助。

这引出了我想让你理解的第一点。仅仅达到 100%的代码覆盖率是不够的。你需要确保测试了所有需要测试的内容。寻找那些即使不会提高你的代码覆盖率,也应该进行测试的边缘情况。

然后寻找错误情况。

错误情况可能会驱使你添加额外的检查以确保错误情况得到适当处理。TDD(测试驱动开发)是驱动这些条件的好方法。或者,你可能会决定改变设计,使错误情况不再适用。

例如,检查一个负分是否通过是否有意义?如果是这样,肯定要添加一个测试,然后添加代码使测试通过。这是我会放入新测试中的事情。记住在每次测试中只有一个确认与允许多个确认之间的平衡?

将调用isPassingGrade的 0、59、60 和 100 的值的所有确认包含在一个测试中是有意义的。至少对我来说是这样的。

然而,使用-1 的值调用isPassingGrade是足够不同的,应该有它自己的测试。或者,考虑这个测试可能足以让你改变设计,使isPassingGrade不再接受 int 参数,而你决定使用无符号 int 参数。对于这个特定的例子,我可能会使用无符号 int。这意味着我们不再需要为-1 或任何负数成绩进行测试。

但关于超过 100 分的成绩呢?也许应该允许它们作为额外学分成绩。如果是这样,那么添加一个新的测试来测试超过 100 分的成绩,并确保它们通过。你可能会发现 101、110 和 1,000,000 这些值很有趣。

为什么是 101、110 和 1,000,000 这些值呢?好吧,101 是一个边缘值,应该包含在内。110 的值看起来像是一个合理的额外加分值。而 1,000,000 的值是一个很好的例子,它是一个荒谬的值,应该包含在内以确保代码不会因为一些意外的异常而失败。你甚至可以考虑将 1,000,000 的值放在自己的测试中。

错误情况应该被测试。理想情况下,你可以在编写测试时考虑错误情况,并在添加处理错误条件的代码之前先编写测试。例如,如果你决定任何超过 1,000 分的成绩都应该抛出异常,那么就编写一个期望异常的测试,并用 1,000 的值调用isPassingGrade以确保它确实抛出了异常。

关于测试错误情况的最后一个想法是:我见过很多没有使用 TDD(测试驱动开发)设计的代码,而且让我印象深刻的是,很多这样的代码中错误情况测试起来非常困难。有时,添加某些错误情况的测试已经不再可行,因为它们太难隔离,而且很难让它们运行,以便测试可以验证代码的响应。

一旦你开始遵循 TDD,你会发现你的测试覆盖率要好得多。这是因为你首先设计了测试,包括错误情况的测试。这迫使你从一开始就设计出可测试的设计。

摘要

在本章中,你学习了如何编写可以在测试结束前就检测到失败的测试。你学习了如何使用确认来确保实际值与你期望的值相匹配。然而,本章只解释了如何检查布尔值。你还需要检查许多其他类型的值,例如以下内容:

  • 你可能有一个需要确认的数字,比如计数。

  • 你可能需要检查一个字符串值,以确保它包含你期望的文本。

下一章将添加这些额外的类型,并解释比较分数或浮点数值时常见的一个问题。

第五章:添加更多确认类型

上一章介绍了确认,并展示了如何使用它们来验证测试中的布尔值是否与预期相符。这一章通过基于学校评分示例的探索性代码来完成。我们将更改评分示例以更好地适应测试库,并添加你可以在确认中使用的一些附加类型。

在本章中,我们将涵盖以下主要主题:

  • 修复布尔确认

  • 确认相等性

  • 修改代码以修复行号导致测试失败的问题

  • 添加更多确认类型

  • 确认字符串字面量

  • 确认浮点值

  • 如何编写确认

这些附加类型为确认添加了一些新的变化,在本章中,你将学习如何应对。到本章结束时,你将能够编写可以验证任何需要测试的结果的测试。

技术要求

本章中的所有代码都使用基于任何现代 C++ 17 或更高版本编译器和标准库的标准 C++。代码基于前几章并继续发展。

你可以在这个 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

修复布尔确认

上一章探讨了确认一个值的意义。然而,它留下了一些我们需要修复的临时代码。让我们首先修复Confirm.cpp中的代码,使其不再引用学校评分。我们希望确认可以与如 bool 这样的类型一起工作。这就是为什么我们现在的确认宏被称为CONFIRM_TRUECONFIRM_FALSE。宏名称中提到的 true 和 false 是预期值。此外,这些宏接受一个参数,即实际值。

我们可以不用关于通过成绩的测试,而是用关于布尔值的测试来替换它:

TEST("Test bool confirms")
{
    bool result = isNegative(0);
    CONFIRM_FALSE(result);
    result = isNegative(-1);
    CONFIRM_TRUE(result);
}

新的测试清楚地说明了它测试的内容,需要一个名为isNegative的新辅助函数,而不是之前的确定成绩是否通过的功能。我想找到一个简单且可以生成具有明显预期值的结果的函数。isNegative函数替换了之前的isPassingGrade函数,其外观如下:

bool isNegative (int value)
{
    return value < 0;
}

这是一个简单的更改,移除了基于成绩的探索性代码,现在它适合测试库。现在,在下一节中,我们可以继续使用测试相等性的确认。

确认相等性

从某种意义上说,布尔确认确实是在测试相等性。它们确保实际布尔值等于预期值。这也是本章引入的新确认将要做的。唯一的区别是,CONFIRM_TRUECONFIRM_FALSE确认不需要接受预期值参数。它们的预期值隐含在它们的名称中。我们可以为布尔类型做这件事,因为只有两种可能的值。

然而,假设我们想要验证实际整数值是否等于 1。我们真的想要一个名为CONFIRM_1的宏吗?我们需要数十亿个宏来为每个可能的 32 位整型值创建宏,对于 64 位整型值则需要更多。使用这种方法验证文本字符串以确保它们与预期值匹配变得不可能。

相反,我们只需要修改其他类型的宏,以便接受预期值和实际值。如果这两个值不相等,则宏应该导致测试失败,并显示适当的错误消息,解释期望值和实际接收到的值。

宏不是用来解析不同类型的。它们只执行简单的文本替换。我们需要真正的 C++函数才能正确地与我们将要检查的不同类型一起工作。此外,我们还可以将现有的布尔宏更改为调用函数,而不是直接在宏中定义代码。以下是我们在上一章中定义的现有布尔宏:

#define CONFIRM_FALSE( actual ) \
if (actual) \
{ \
    throw MereTDD::BoolConfirmException(false, __LINE__); \
}
#define CONFIRM_TRUE( actual ) \
if (not actual) \
{ \
    throw MereTDD::BoolConfirmException(true, __LINE__); \
}

我们需要做的是将ifthrow语句移动到函数中。我们只需要一个函数来处理真和假,它将看起来像这样:

inline void confirm (
    bool expected,
    bool actual,
    int line)
{
    if (actual != expected)
    {
        throw BoolConfirmException(expected, line);
    }
}

这个函数可以放在MereTDD命名空间内的Test.h文件中,在TestBase定义之前。该函数需要是内联的,并且由于它现在位于同一命名空间中,因此不再需要使用命名空间来限定异常。

此外,你可以更清楚地看到,即使是对于布尔值,这也是一个相等比较。该函数检查确保实际值等于预期值,如果不等于,则抛出异常。宏可以简化为调用新函数,如下所示:

#define CONFIRM_FALSE( actual ) \
    MereTDD::confirm(false, actual, __LINE__)
#define CONFIRM_TRUE( actual ) \
    MereTDD:: confirm(true, actual, __LINE__)

构建和运行结果显示所有测试都通过了,我们现在可以添加额外的类型来确认。让我们从Confirm.cpp中的新测试开始,用于整型值,如下所示:

TEST("Test int confirms")
{
    int result = multiplyBy2(0);
    CONFIRM(0, result);
    result = multiplyBy2(1);
    CONFIRM(2, result);
    result = multiplyBy2(-1);
    CONFIRM(-2, result);
}

与布尔值不同,此代码测试整数值。它使用一个新的辅助函数,这个函数应该很容易理解,它只是将一个值乘以 2。我们需要在文件顶部声明这个新辅助函数,如下所示:

int multiplyBy2 (int value)
{
    return value * 2;
}

测试目前还不能构建。这是可以接受的,因为当我们使用 TDD 方法时,我们希望首先关注使用。这种使用看起来很好。它将使我们能够确认任何整数值都等于我们期望它成为的值。让我们创建CONFIRM宏,并将其放置在两个现有的确认真和假的宏之后,如下所示:

#define CONFIRM_FALSE( actual ) \
    MereTDD::confirm(false, actual, __LINE__)
#define CONFIRM_TRUE( actual ) \
    MereTDD:: confirm(true, actual, __LINE__)
#define CONFIRM( expected, actual ) \
    MereTDD::confirm(expected, actual, __LINE__)

将宏更改为调用函数现在真的很有成效。CONFIRM宏需要一个额外的参数来传递预期值,并且可以调用相同的函数名。然而,它是如何调用相同的函数呢?嗯,那是因为我们将要重载函数。我们现在拥有的只适用于布尔值。这就是为什么我们转向了一个可以利用数据类型的设计。我们只需要提供另一个confirm的实现,使其可以重载以处理整数,如下所示:

inline void confirm (
    int expected,
    int actual,
    int line)
{
    if (actual != expected)
    {
        throw ActualConfirmException(expected, actual, line);
    }
}

这几乎与现有的confirm函数相同。它接受预期和实际参数为整数,而不是布尔值,并将抛出一个新的异常类型。引入新异常类型的原因是我们可以格式化一个将显示预期和实际值的失败消息。BoolConfirmException类型将仅用于布尔值,并将格式化一个只提及预期的消息。此外,新的ActualConfirmException类型将格式化一个提及预期和实际值的消息。

新的异常类型如下:

class ActualConfirmException : public ConfirmException
{
public:
    ActualConfirmException (int expected, int actual, int line)
    : mExpected(std::to_string(expected)),
      mActual(std::to_string(actual)),
      mLine(line)
    {
        formatReason();
    }
private:
    void formatReason ()
    {
        mReason =  "Confirm failed on line ";
        mReason += std::to_string(mLine) + "\n";
        mReason += "    Expected: " + mExpected + "\n";
        mReason += "    Actual  : " + mActual;
    }
    std::string mExpected;
    std::string mActual;
    int mLine;
};

你可能想知道为什么新的异常类型将预期和实际值存储为字符串。构造函数接受整数,然后在格式化原因之前将整数转换为字符串。这是因为我们将添加多个数据类型,我们实际上不需要做任何不同的事情。每种类型只需要在测试失败时根据字符串显示描述性消息。

我们不需要使用预期或实际值进行任何计算。它们只需要被格式化为可读的消息。此外,这种设计将使我们能够使用单个异常处理所有除了布尔值之外的数据类型。我们也可以为布尔值使用这个新异常,但对于布尔值,消息不需要提及实际值。因此,我们将保留现有的布尔值异常,并使用这个新的异常类型来处理其他所有情况。

通过将预期和实际值存储为字符串,我们需要的只是为每个我们想要支持的新数据类型提供一个重载构造函数。每个构造函数都可以将预期和实际值转换为字符串,然后可以将其格式化为可读的消息。这比有一个IntActualConfirmException类、一个StringActualConfirmException类等等要好。

我们可以再次构建和运行测试。布尔和整数测试的结果如下:

---------------
Test bool confirms
Passed
---------------
Test int confirms
Passed
---------------

那么,如果确认失败会发生什么?嗯,我们在上一章已经看到了失败的布尔确认是什么样子。但我们还没有任何针对失败情况的测试。我们应该添加它们,并使它们成为预期失败,以便可以捕获行为。即使是失败也应该进行测试,以确保它仍然是失败。如果将来我们对代码进行了某些更改,将失败变成了成功,那将是一个破坏性的变化,因为失败应该是预期的。让我们向Confirm.cpp添加几个新的测试,如下所示:

TEST("Test bool confirm failure")
{
    bool result = isNegative(0);
    CONFIRM_TRUE(result);
}
TEST("Test int confirm failure")
{
    int result = multiplyBy2(1);
    CONFIRM(0, result);
}

我们获取预期的失败,它们看起来像这样:

---------------
Test bool confirm failure
Failed
Confirm failed on line 41
    Expected: true
---------------
Test int confirm failure
Failed
Confirm failed on line 47
    Expected: 0
    Actual  : 2
---------------

下一步是设置预期的错误消息,以便这些测试通过而不是失败。然而,有一个问题。行号是错误消息的一部分。我们希望行号在测试结果中显示。但这意味着我们也必须在预期的失败消息中包含行号,以便将失败视为通过。这为什么会成为问题呢?嗯,那是因为每次测试被移动,甚至当其他测试被添加或删除时,行号都会改变。我们不想不得不更改预期的错误消息,因为这不是错误真正的一部分。行号告诉我们错误发生的位置,不应该成为错误发生原因的一部分。

在下一节中,我们将通过一些重构来修复行号。

将测试失败与行号解耦

我们需要从确认失败原因中删除行号,以便测试可以给出一个不会随着测试移动或转移到源代码文件的不同位置而改变的预期失败原因。

这种类型的更改被称为重构。我们不会做出导致代码中出现不同或新行为的更改。至少,这是目标。使用 TDD 将帮助你重构代码,因为你应该已经为所有重要方面都设置了测试。

使用适当的测试进行重构可以让你验证没有任何东西发生变化。很多时候,为了避免引入新的错误,人们会避免在没有 TDD 的情况下进行重构。这往往会使问题变得更严重,因为重构被推迟或完全避免。

我们在行号上遇到了问题。我们本可以忽略这个问题,并在任何更改发生时只需更新测试中的预期失败消息中的新行号。但这是不正确的,只会导致更多的工作和脆弱的测试。随着测试的增加,问题只会变得更糟。我们真的应该现在解决这个问题。因为我们遵循 TDD,我们可以确信我们即将做出的更改不会破坏已经测试过的任何东西。或者,至少,如果它确实破坏了,我们会知道并立即修复任何破坏。

第一步是在Test.cpp中的ConfirmException基类中添加行号信息:

class ConfirmException
{
public:
    ConfirmException (int line)
    : mLine(line)
    { }
    virtual ~ConfirmException () = default;
    std::string_view reason () const
    {
        return mReason;
    }
    int line () const
    {
        return mLine;
    }
protected:
    std::string mReason;
    int mLine;
};

然后,在runTests函数中,我们可以从确认异常中获取行号,并使用它来设置测试中的失败位置,如下所示:

        try
        {
            test->runEx();
        }
        catch (ConfirmException const & ex)
        {
            test->setFailed(ex.reason(), ex.line());
        }

即使我们没有从测试开始,请注意我仍然在遵循 TDD 方法来编写代码,因为我希望在完全实现之前使用它。这是一个很好的例子,因为我最初考虑向测试类添加一个新方法。它被称为setFailedLocation。但这样做让现有的setFailed方法看起来很奇怪。我几乎将setFailed重命名为setFailedReason,这将意味着它需要在其他被调用的地方进行更改。相反,我决定向现有的setFailed方法添加一个额外的行号参数。我还决定给参数一个默认值,这样其他代码就不需要更改。这很有意义,并允许调用者自行设置失败原因,或者如果知道行号,则可以同时设置。

我们需要向TestBase类添加一个行号数据成员。行号将仅适用于确认,因此它将被称为mConfirmLocation,如下所示:

    std::string mName;
    bool mPassed;
    std::string mReason;
    std::string mExpectedReason;
    int mConfirmLocation;
};

新的数据成员需要在TestBase构造函数中初始化。我们将使用-1 的值来表示行号位置不适用:

    TestBase (std::string_view name)
    : mName(name), mPassed(true), mConfirmLocation(-1)
    { }

我们需要像这样向setFailed方法添加行号参数:

    void setFailed (std::string_view reason,          int confirmLocation = -1)
    {
        mPassed = false;
        mReason = reason;
        mConfirmLocation = confirmLocation;
    }

此外,我们还需要为确认位置添加一个新的 getter 方法,如下所示:

    int confirmLocation () const
    {
        return mConfirmLocation;
    }

这将允许runTests函数在捕获到确认异常时设置行号,并且测试将能够记住行号。在runTests的末尾,当将失败消息发送到输出时,我们需要测试confirmLocation,并根据是否有行号来更改输出,如下所示:

        else
        {
            ++numFailed;
            if (test->confirmLocation() != -1)
            {
                output << "Failed confirm on line "
                    << test->confirmLocation() << "\n";
            }
            else
            {
                output << "Failed\n";
            }
            output << test->reason()
                << std::endl;
        }

这也将修复确认中的一个小问题。之前,测试结果打印了一条说测试失败的行,然后又打印了一条说确认失败的行。新的代码将只显示一个通用的失败消息或带有行号的确认失败消息。

我们还没有完成。我们需要更改派生异常类构造函数,以初始化基类行号,并停止将行号作为原因的一部分。BoolConfirmException的构造函数如下所示:

    BoolConfirmException (bool expected, int line)
    : ConfirmException(line)
    {
        mReason += "    Expected: ";
        mReason += expected ? "true" : "false";
    }

此外,ActualConfirmException类需要在整个文件中进行更改。构造函数需要使用行号初始化基类,格式需要更改,并且可以删除行号数据成员,因为它现在在基类中。类看起来如下所示:

class ActualConfirmException : public ConfirmException
{
public:
    ActualConfirmException (int expected, int actual, int line)
    : ConfirmException(line),
      mExpected(std::to_string(expected)),
      mActual(std::to_string(actual))
    {
        formatReason();
    }
private:
    void formatReason ()
    {
        mReason += "    Expected: " + mExpected + "\n";
        mReason += "    Actual  : " + mActual;
    }
    std::string mExpected;
    std::string mActual;
};

我们可以再次构建并运行,仍然显示预期的失败。失败原因的格式与之前略有不同,如下所示:

---------------
Test bool confirm failure
Failed confirm on line 41
    Expected: true
---------------
Test int confirm failure
Failed confirm on line 47
    Expected: 0
    Actual  : 2
---------------

它看起来几乎一样,这是好的。现在我们可以设置预期的失败消息,而不用担心行号,如下所示:

TEST("Test bool confirm failure")
{
    std::string reason = "    Expected: true";
    setExpectedFailureReason(reason);
    bool result = isNegative(0);
    CONFIRM_TRUE(result);
}
TEST("Test int confirm failure")
{
    std::string reason = "    Expected: 0\n";
    reason += "    Actual  : 2";
    setExpectedFailureReason(reason);
    int result = multiplyBy2(1);
    CONFIRM(0, result);
}

注意,预期的失败原因需要格式化,以与测试失败时显示的内容完全匹配。这包括用于缩进的空格和新行。一旦设置了预期的失败原因,所有的测试就会再次通过,如下所示:

---------------
Test bool confirm failure
Expected failure
    Expected: true
---------------
Test int confirm failure
Expected failure
    Expected: 0
    Actual  : 2
---------------

这两个测试都预期会失败,并且被视为通过。现在我们可以继续添加更多确认类型。

添加更多确认类型

目前,我们可以在测试中确认 bool 和 int 值。我们需要更多,所以下一步应该添加什么?让我们添加对 long 类型的支持。它与 int 类似,在许多平台上将有效地相同。即使它可能或可能不使用与 int 相同数量的位,对于 C++ 编译器来说,它是一个不同的类型。我们可以通过在 Confirm.cpp 中添加一个基本的测试来开始,这个测试像这样测试 long 类型:

TEST("Test long comfirms")
{
    long result = multiplyBy2(0L);
    CONFIRM(0L, result);
    result = multiplyBy2(1L);
    CONFIRM(2L, result);
    result = multiplyBy2(-1L);
    CONFIRM(-2L, result);
}

测试调用相同的 multiplyBy2 辅助函数,因为它不是在整个过程中使用 long 类型。我们通过添加 L 后缀以 long 文字值开始。这些值被转换为 int 以传递给 multiplyBy2。返回值也是一个 int,它被转换为 long 以分配给 result。让我们通过创建一个接受 long 类型并返回 long 类型的重载 multiplyBy2 版本来防止所有这些额外的转换:

long multiplyBy2 (long value)
{
    return value * 2L;
}

如果我们现在尝试构建,将会出现错误,因为编译器不知道应该调用哪个重载的 confirm 函数。唯一可用的选择是将预期的长值和实际值转换为 int 或 bool。这两种选择都不匹配,编译器将调用视为模糊的。记住,CONFIRM 宏会被转换成对重载的 confirm 函数的调用。

我们可以通过添加一个新的重载 confirm 版本,该版本使用 long 参数来解决这个问题。然而,更好的解决方案是将现有的使用 int 参数的 confirm 版本改为模板,如下所示:

template <typename T>
void confirm (
    T const & expected,
    T const & actual,
    int line)
{
    if (actual != expected)
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

我们仍然有使用 bool 参数的 confirm 版本。模板将匹配 int 和 long 类型。此外,模板还将匹配我们尚未测试的类型。新的模板 confirm 方法在创建要抛出的异常时也会将类型转换为 std::string。在 第十二章创建更好的测试确认,你会看到我们在将预期值和实际值转换为字符串的方式上存在问题。或者至少,有更好的方法。我们目前的方法是可行的,但仅适用于可以传递给 std::to_string 的数值类型。

让我们更新 ActualConfirmException 构造函数,使其使用字符串,我们将在 confirm 函数内部调用 std::to_string。构造函数看起来像这样:

    ActualConfirmException (
        std::string_view expected,
        std::string_view actual,
        int line)
    : ConfirmException(line),
      mExpected(expected),
      mActual(actual)
    {
        formatReason();
    }

一切构建正常,所有测试都通过了。我们可以在 Confirm.cpp 中添加一个新的测试,用于测试 long 失败,如下所示:

TEST("Test long confirm failure")
{
    std::string reason = "    Expected: 0\n";
    reason += "    Actual  : 2";
    setExpectedFailureReason(reason);
    long result = multiplyBy2(1L);
    CONFIRM(0L, result);
}

失败原因字符串与 int 相同,即使我们正在测试 long 类型。新测试的测试结果如下:

---------------
Test long confirm failure
Expected failure
    Expected: 0
    Actual  : 2
---------------

让我们尝试一个会显示不同结果的类型。long long 类型可以肯定地存储比 int 更大的数值。下面是 Confirm.cpp 中的一个新测试,用于测试 long long 值:

TEST("Test long long confirms")
{
    long long result = multiplyBy2(0LL);
    CONFIRM(0LL, result);
    result = multiplyBy2(10'000'000'000LL);
    CONFIRM(20'000'000'000LL, result);
    result = multiplyBy2(-10'000'000'000LL);
    CONFIRM(-20'000'000'000LL, result);
}

对于long long类型,我们可以有大于最大 32 位有符号值的值。代码使用单引号来使较大的数字更容易阅读。编译器忽略单引号,但它们帮助我们视觉上分隔每一组千位数。此外,后缀LL告诉编译器将字面值视为long long类型。

这个通过测试的结果看起来和其他的一样:

---------------
Test long long confirms
Passed
---------------

我们需要查看一个长长的失败测试结果来查看更大的数字。这里是一个失败测试:

TEST("Test long long confirm failure")
{
    std::string reason = "    Expected: 10000000000\n";
    reason += "    Actual  : 20000000000";
    setExpectedFailureReason(reason);
    long long result = multiplyBy2(10'000'000'000LL);
    CONFIRM(10'000'000'000LL, result);
}

由于我们不使用分隔符格式化输出,我们需要使用不带逗号的纯数字文本格式。这可能是最好的方式,因为一些地区使用逗号,而一些地区使用点。注意,我们不做任何格式化尝试,所以期望的失败消息也不使用任何格式化。

现在,我们可以看到失败描述确实与较大的数字匹配,看起来像这样:

---------------
Test long long confirm failure
Expected failure
    Expected: 10000000000
    Actual  : 20000000000
---------------

我想强调关于失败测试的一个重要观点。它们故意使用不正确的期望值来强制失败。你不会在测试中这样做。但你也无需编写你希望失败的测试。我们希望这些测试失败,以便我们可以验证测试库能够正确地检测和处理任何失败。因此,我们将这些失败视为通过。

我们可以继续添加对短整型、字符和所有无符号版本的测试。然而,在这个点上,这变得不再有趣,因为我们只是在测试模板函数是否正常工作。相反,让我们专注于使用非模板代码的类型,这些代码已经被编写来正常工作。

这里是对字符串类型的一个简单测试:

TEST("Test string confirms")
{
    std::string result = "abc";
    std::string expected = "abc";
    CONFIRM(expected, result);
}

而不是编写一个返回字符串的假辅助方法,这个测试只是声明了两个字符串,并将使用一个作为实际值,另一个作为期望值。通过将两个字符串都初始化为相同的文本,我们期望它们相等,所以我们调用CONFIRM来确保它们相等。

当你编写测试时,你将想要给result分配一个从你正在测试的函数或方法中获得的值。我们的目标是测试CONFIRM宏和底层测试库代码是否正常工作。因此,我们可以跳过被测试的函数,直接使用两个字符串值进行宏测试,其中我们知道期望的结果。

这看起来像是一个合理的测试。而且确实是。但它无法编译。问题是confirm模板函数试图在提供的值上调用std::to_string。当值已经是字符串时,这没有意义。

我们需要的是一个新的confirm重载,它使用字符串。我们实际上会创建两个重载,一个用于字符串视图,一个用于字符串。第一个重载函数看起来像这样:

inline void confirm (
    std::string_view expected,
    std::string_view actual,
    int line)
{
    if (actual != expected)
    {
        throw ActualConfirmException(
            expected,
            actual,
            line);
    }
}

这个第一个函数接受字符串视图,与模板方法相比,在处理字符串视图时将是一个更好的匹配。然后,它将给定的字符串传递给ActualConfirmException构造函数,而不尝试调用std::to_string,因为它们已经是字符串。

第二个重载函数看起来像这样:

inline void confirm (
    std::string const & expected,
    std::string const & actual,
    int line)
{
    confirm(
        std::string_view(expected),
        std::string_view(actual),
        line);
}

这个第二个函数接受常量字符串引用,与模板方法相比,在处理字符串时将是一个更好的匹配。然后,它将字符串转换为字符串视图并调用第一个函数。

现在,我们可以添加一个字符串失败测试,如下所示:

TEST("Test string confirm failure")
{
    std::string reason = "    Expected: def\n";
    reason += "    Actual  : abc";
    setExpectedFailureReason(reason);
    std::string result = "abc";
    std::string expected = "def";
    CONFIRM(expected, result);
}

构建和运行测试后的测试结果如下:

---------------
Test string confirm failure
Expected failure
    Expected: def
    Actual  : abc
---------------

关于字符串,还有一个重要的方面需要考虑。我们需要考虑真正的常量字符指针的字符串字面量。我们将在下一节中探讨跟随字符串字面量的指针。

确认字符串字面量

字符串字面量可能看起来像字符串,但 C++编译器将字符串字面量视为指向一组常量字符的第一个字符的指针。常量字符集以空字符值终止,这是零的数值。这就是编译器知道字符串有多长的方式。它只是继续进行,直到找到空字符。字符是常量的原因在于数据通常存储在写保护的内存中,因此不能被修改。

当我们尝试确认一个字符串字面量时,编译器看到的是一个指针,必须决定调用哪个重载的confirm函数。在我们深入探索字符串字面量之前,我们可能会遇到哪些与指针相关的问题?

让我们从简单的 bool 类型开始,看看如果我们尝试确认 bool 指针时会遇到什么问题。这将帮助你通过首先理解一个简单的 bool 指针示例测试来理解字符串字面量指针。你不需要将此测试添加到项目中。它被包含在这里只是为了解释当我们尝试确认指针时会发生什么。测试看起来是这样的:

TEST("Test bool pointer confirms")
{
    bool result1 = true;
    bool result2 = false;
    bool * pResult1 = &result1;
    bool * pResult2 = &result2;
    CONFIRM_TRUE(pResult1);
    CONFIRM_FALSE(pResult2);
}

前面的测试实际上是可以编译和运行的。但它以以下结果失败:

---------------
Test bool pointer confirms
Failed confirm on line 86
    Expected: false
---------------

第 86 行是测试中的第二个确认。那么,发生了什么?为什么确认认为pResult2指向一个真值?

好吧,记住,confirm 宏只是被替换为对confirm方法之一的调用。第二个确认处理以下宏:

#define CONFIRM_FALSE( actual ) \
    confirm(false, actual, __LINE__)

然后它尝试使用硬编码的假 bool 值、传递给宏的 bool 指针和整行号调用confirm。对于任何版本的confirm,都没有 bool、bool 指针或 int 的确切匹配,所以要么必须进行转换,否则编译器将生成错误。我们知道没有错误,因为代码编译并运行了。那么,转换了什么?

这是对 TDD 过程的一个很好的例子,如第三章“TDD 过程”中所述,首先编写你希望它使用的代码,即使你预期构建会失败也要编译它。在这种情况下,构建没有失败,这让我们得到了我们可能错过的洞察。

编译器能够将指针值转换为布尔值,并且这被视为最佳选择。实际上,我甚至没有收到关于转换的警告。编译器默默地做出了将指针转换为布尔值的决定,并将其转换为布尔值。这几乎从来不是你想要发生的事情。

那么,将指针转换为布尔值究竟是什么意思呢?任何具有有效非零地址的指针都会转换为 true。此外,任何具有零地址的空指针都会转换为 false。因为我们已经将result2的实际地址存储在pResult2指针中,所以转换成了真实的布尔值。

你可能想知道第一个确认发生了什么,为什么它没有失败。为什么测试在失败之前继续进行到第二个确认?嗯,第一个确认对布尔值、布尔指针和整型进行了相同的转换。两种转换都产生了真实的布尔值,因为两个指针都持有有效的地址。

第一次确认调用confirm时传递了 true、true 和行号,这通过了。但第二次确认调用confirm时传递了 false、true 和行号,这失败了。

为了解决这个问题,我们或者需要添加对所有类型指针的支持,或者记得在确认之前解引用指针。添加对指针的支持可能看起来像是一个简单的解决方案,直到我们到达字符串字面量,它们也是指针。这并不像看起来那么简单,而且现在我们不需要这样做。让我们保持测试库尽可能简单。以下是如何修复前面显示的布尔确认测试的方法:

TEST("Test bool pointer dereference confirms")
{
    bool result1 = true;
    bool result2 = false;
    bool * pResult1 = &result1;
    bool * pResult2 = &result2;
    CONFIRM_TRUE(*pResult1);
    CONFIRM_FALSE(*pResult2);
}

注意,测试解引用了指针而不是直接将指针传递给宏。这意味着测试实际上只是在测试布尔值,这就是为什么我说你实际上不需要添加测试。

字符串字面量在源代码中很常见。它们是表示预期字符串值的一种简单方法。字符串字面量的问题是它们不是字符串。它们是一个指向常量字符的指针。我们无法像对布尔指针那样解引用字符串字面量指针。那将导致一个单独的字符。我们想要确认整个字符串。

这里有一个测试,展示了字符串字面量可能的主要用法。最常见的使用是将字符串字面量与字符串进行比较。测试看起来是这样的:

TEST("Test string and string literal confirms")
{
    std::string result = "abc";
    CONFIRM("abc", result);
}

这之所以有效,是因为最终传递给confirm函数的参数类型之一是std::string。编译器没有找到两个参数的精确匹配;然而,因为一个是字符串,它决定将字符串字面量也转换为字符串。

我们遇到问题的地方在于当我们尝试确认预期值和实际值的两个字符串字面量时。编译器看到两个指针,并不知道它们都应该被转换为字符串。这不是你需要在测试中验证的正常情况。另外,如果你确实需要比较两个字符串字面量,在确认之前将其中一个包裹成std::string参数类型很容易。

此外,在第十二章 创建更好的测试确认方法中,你会看到如何解决确认两个字符串字面量的问题。我们将改进用于确认测试结果的整体设计。我们现在所使用的设计通常被称为确认值的经典方法。第十二章将介绍一种更可扩展、更易读、更灵活的新方法。

在支持不同类型方面我们已经取得了长足的进步,你也理解了如何处理字符串字面量。然而,我避开浮点型和双精度浮点型,因为它们需要特别的考虑。它们将在下一节中解释。

确认浮点值

在最基本层面上,确认工作是通过比较预期值与实际值,并在它们不同时抛出异常来完成的。这对于所有整型,如 int 和 long,布尔类型,甚至是字符串都适用。值要么匹配,要么不匹配。

对于浮点型和双精度浮点型,事情变得困难,因为并不总是能够准确比较两个浮点值。

即使在我们从小学就熟悉的十进制系统中,我们也知道存在一些无法准确表示的分数值。例如,1/3 很容易表示为分数。但是,以浮点十进制格式书写时,看起来像 0.33333,数字 3 无限循环。我们可以接近 1/3 的真实值,但在某个点上,我们必须在书写 0.333333333...时停止。无论我们包含多少个 3,总是还有更多。

在 C++中,浮点值使用具有类似精度问题的二进制数系统。但二进制中的精度问题比十进制中更为常见。

我不会深入所有细节,因为它们并不重要。然而,二进制中额外问题的主要原因是 2 的因子比 10 的因子少。在十进制系统中,因子是 1、2、5 和 10。而在二进制中,2 的因子只有 1 和 2。

那么,为什么因子很重要呢?嗯,这是因为它们决定了哪些分数可以准确描述,哪些不能。例如,1/3 这个分数对两个系统都造成麻烦,因为 3 在两个系统中都不是因子。另一个例子是 1/7。这些分数并不常见。1/10 的分数在十进制中非常常见。因为 10 是一个因子,这意味着像 0.1、0.2、0.3 等值都可以在十进制中准确表示。

此外,由于 10 不是二进制基数 2 的因子,这些广泛使用的相同值在十进制中没有用固定数字表示的表示,就像它们在十进制中那样。

所以,这一切意味着,如果你有一个看起来像 0.1 的二进制浮点值,它接近实际值,但无法完全精确。它可能在转换为字符串时显示为 0.1,但这也涉及一点舍入。

通常,我们不会担心计算机无法准确表示我们从小学就习惯于精确表示的值——也就是说,直到我们需要测试一个浮点值以查看它是否等于另一个值。

即使像 0.1 + 0.2 这样看起来等于 0.3 的简单运算,也可能不等于 0.3。

当比较计算机浮点值时,我们总是必须允许一定量的误差。只要值接近,我们就可以假设它们相等。

然而,最终的问题是,没有好的单一解决方案可以确定两个值是否接近。我们可以表示的误差量取决于值的大小。当浮点值接近 0 时,它们会急剧变化。并且当它们变大时,它们失去了表示小值的能力。因为浮点值可以变得非常大,所以大值丢失的精度也可能很大。

让我们想象一下,如果一家银行使用浮点值来跟踪你的钱。如果你有数十亿美元,但银行却无法跟踪低于一千美元的任何金额,你会高兴吗?我们不再谈论丢失几美分的问题。或者,也许你的账户里只有 30 美分,你想取出所有的 30 美分。你会期望银行拒绝你的取款,因为它认为 30 美分比你账户里的 30 美分多吗?这些问题就是浮点值可能导致的。

由于我们正在遵循 TDD(测试驱动开发)流程,我们将从简单的浮点值开始,并在比较浮点、双精度或长双精度值时包含一个小的误差范围,以查看它们是否相等。我们不会变得复杂,试图根据值的的大小调整误差范围。

这里是一个我们将用于浮点值的测试:

TEST("Test float confirms")
{
    float f1 = 0.1f;
    float f2 = 0.2f;
    float sum = f1 + f2;
    float expected = 0.3f;
    CONFIRM(expected, sum);
}

浮点类型的测试实际上在我的电脑上通过了。

那么,如果我们为双精度类型创建另一个测试会发生什么呢?新的双精度测试看起来像这样:

TEST("Test double confirms")
{
    double d1 = 0.1;
    double d2 = 0.2;
    double sum = d1 + d2;
    double expected = 0.3;
    CONFIRM(expected, sum);
}

这个测试几乎相同,但在我的电脑上失败了。而且,奇怪的是,除非你理解值可以以文本形式打印出来,并且已经被调整为看起来像是一个很好的圆整数,否则失败描述是没有意义的。以下是我电脑上的失败信息:

---------------
Test double confirms
Failed confirm on line 122
    Expected: 0.300000
    Actual  : 0.300000
---------------

看到这条消息,你可能会问,为什么 0.300000 不等于 0.300000。原因是预期的值和实际的值都不是精确的 0.300000。它们都被稍微调整了一下,以便它们会显示这些看起来像圆整的值。

对于长双精度浮点数(long doubles)的测试几乎与双精度浮点数(doubles)的测试相同。只是类型发生了变化,如下所示:

TEST("Test long double confirms")
{
    long double ld1 = 0.1;
    long double ld2 = 0.2;
    long double sum = ld1 + ld2;
    long double expected = 0.3;
    CONFIRM(expected, sum);
}

长双精度浮点数测试在我的机器上也因为与双精度浮点数测试相同的原因而失败。我们可以通过为这三种类型添加特殊重载来修复所有的浮点数确认。

这里是一个重载的confirm函数,它在比较浮点值时使用了一个小的误差范围:

inline void confirm (
    float expected,
    float actual,
    int line)
{
    if (actual < (expected - 0.0001f) ||
        actual > (expected + 0.0001f))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

我们需要的重载几乎与浮点数相同。以下是一个双精度浮点数的重载,它使用一个误差范围,这个误差范围是预期值的正负:

inline void confirm (
    double expected,
    double actual,
    int line)
{
    if (actual < (expected - 0.000001) ||
        actual > (expected + 0.000001))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

除了从浮点数到双精度浮点数的类型变化之外,这种方法使用了一个更小的误差范围,并且从字面值中省略了f后缀。

长双精度浮点数的重载函数与双精度浮点数的类似,如下所示:

inline void confirm (
    long double expected,
    long double actual,
    int line)
{
    if (actual < (expected - 0.000001) ||
        actual > (expected + 0.000001))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

在为浮点数、双精度浮点数和长双精度浮点数添加了这些重载(overloads)之后,所有的测试都再次通过。我们将在第十三章如何测试浮点数和自定义值中再次回顾比较浮点值的问题。我们目前拥有的比较解决方案很简单,并且现在可以工作。

我们已经涵盖了我们将要支持的所有确认类型。记住 TDD 规则,只做必要的事情。我们总是可以在以后增强确认的设计,这正是我们将在第十二章创建更好的测试确认中要做的事情。

在结束这一章之前,我有一些关于编写确认的建议。这并不是我们还没有做的事情,但它确实值得提一下,以便你知道这个模式。

如何编写确认(confirms)

通常,你有很多不同的方式可以编写你的代码和测试。我在这里分享的是基于多年的经验,虽然这并不是编写测试的唯一方式,但我希望你能从中学习,并遵循类似的风格。具体来说,我想分享关于如何编写确认(confirms)的指导。

最重要的是要记住,将你的确认放在测试的正常流程之外,但仍然靠近它们所需的位置。当测试运行时,它会执行各种活动,你需要确保它们按预期工作。你可以在过程中添加确认以确保测试按预期进行。或者,你可能有一个简单的测试,只做一件事,并在最后需要一到多个确认以确保一切正常。所有这些都是好的。

考虑以下三个测试用例的例子。它们都做同样的事情,但我希望你关注它们的编写方式。以下是第一个例子:

TEST("Test int confirms")
{
    int result = multiplyBy2(0);
    CONFIRM(0, result);
    result = multiplyBy2(1);
    CONFIRM(2, result);
    result = multiplyBy2(-1);
    CONFIRM(-2, result);
}

这个测试是之前用来确保我们可以确认整数值的测试。注意它如何执行一个动作并将结果分配给一个局部变量。然后,检查该变量以确保其值与预期相符。如果相符,测试将继续执行另一个动作并将结果分配给局部变量。这种模式持续进行,如果所有确认都符合预期值,则测试通过。

下面是相同测试用例的另一种写法:

TEST("Test int confirms")
{
    CONFIRM(0, multiplyBy2(0));
    CONFIRM(2, multiplyBy2(1));
    CONFIRM(-2, multiplyBy2(-1));
}

这次,没有局部变量来存储每个动作的结果。有些人可能会认为这是一个改进。它确实更短。但我感觉这隐藏了正在测试的内容。我发现将确认视为可以从测试中移除而不改变测试行为的东西更好。当然,如果你移除了确认,那么测试可能会错过确认本应捕获的问题。我是在谈论心理上忽略确认,以了解测试做了什么,然后思考在过程中哪些内容需要验证。这些验证点变成了确认。

这是另一个例子:

TEST("Test int confirms")
{
    int result1 = multiplyBy2(0);
    int result2 = multiplyBy2(1);
    int result3 = multiplyBy2(-1);
    CONFIRM(0, result1);
    CONFIRM(2, result2);
    CONFIRM(-2, result3);
}

这个例子避免了在确认中放置测试步骤。然而,我感觉它过分地将测试步骤与确认分离。在测试步骤中穿插确认并没有什么问题。这样做可以让你立即发现问题。这个例子将所有确认都放在了最后,这意味着它也必须等到最后才能发现问题。

然后还有这样一个问题,需要多个结果变量以便稍后逐一检查。这段代码在我看来显得有些生硬——就像一个程序员选择了漫长的路径去达到目标,而实际上有一条简单的路径可用。

第一个例子展示了这本书中迄今为止编写的测试风格,现在你可以看到为什么它们要以这种方式编写。它们在需要的地方使用确认,并且尽可能接近验证点。并且它们避免在确认中放置实际的测试步骤。

摘要

本章带我们超越了确认真伪值的基本能力。你现在可以验证任何你需要确保其与预期相符的内容。

我们通过将代码放入重载函数中,并使用模板版本来处理其他类型,简化了确认宏。你看到了如何确认简单数据类型,并通过先解引用来与指针一起工作。

需要重构的代码,你看到了当需要对代码进行设计更改时,TDD 是如何帮助你的。我本可以在本书中编写代码,让它看起来像是从一开始就写得完美无缺。但那样做对你没有帮助,因为没有人从一开始就能写出完美的代码。随着我们对知识的理解不断增长,我们有时需要更改代码。而 TDD 则让你有信心在问题一出现就立即进行更改,而不是等待——因为你推迟解决的问题往往会有扩大的趋势,而不是消失。

你应该正在了解如何编写你的测试,以及将确认信息融入测试的最佳方式。

到目前为止,我们一直在使用 C++ 17 中找到的 C++特性和功能。C++ 20 中有一个重要的新特性,可以帮助我们从编译器中获取行号。下一章将添加这个 C++ 20 特性,并探讨一些替代设计。即使我们保持现在的整体设计不变,下一章也会帮助你理解其他测试库可能如何进行不同的操作。

第六章:早期探索改进

我们在测试库方面已经取得了很大的进步,并且一直在使用 TDD 来达到这里。有时,在项目走得太远之前探索新想法是很重要的。在创建任何事物之后,我们将有在开始时没有的见解。并且在与设计一起工作了一段时间之后,我们将对喜欢什么以及可能想要改变什么有一个感觉。我鼓励你在继续之前花时间反思设计。

我们已经有一些正在工作的事物,并且在使用它方面有一些经验,那么我们是否可以改进些什么?

这种方法类似于 TDD 的高级过程,如第三章《TDD 过程》中所述。首先,我们确定我们想要如何使用某物,然后构建它,然后进行最小的工作量以使其工作并通过测试,然后增强设计。我们现在有很多事物正在工作,但我们还没有走到一个改变会变得过于困难的地步。我们将探讨如何增强整体设计的方法。

在这一点上,环顾四周看看其他类似解决方案并比较它们也是一个好主意。获取想法。并尝试一些新事物,看看它们是否可能更好。我已经这样做,并希望在本章中探讨两个主题:

  • 我们能否使用 C++ 20 的新特性来获取行号,而不是使用__LINE__

  • 如果我们使用 lambda 表达式,测试看起来会是什么样子?

到本章结束时,你将了解在项目设计早期探索改进的重要性和涉及的过程。即使你并不总是决定接受新想法并做出改变,但你的项目会因为你有时间考虑替代方案而变得更好。

技术要求

本章的代码使用标准 C++,我们将尝试 C++ 20 中引入的特性。代码基于并延续前几章。

你可以在这个 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

无宏获取行号

C++ 20 包含了一个新的类,它将帮助我们获取行号。实际上,它包含的信息远不止行号。它包括文件名、函数名,甚至列号。然而,我们只需要行号。请注意,在撰写本书时,这个新类在我编译器的实现中有一个错误。结果是,我不得不将代码放回到本节描述的更改之前的版本。

这个新类被称为source_location,一旦它最终正确工作,我们可以将所有现有的confirm函数更改为接受std::source_location而不是行号的 int。一个现有的confirm函数的例子如下:

inline void confirm (
    bool expected,
    bool actual,
    int line)
{
    if (actual != expected)
    {
        throw BoolConfirmException(expected, line);
    }
}

我们最终可以通过将所有confirm函数,包括模板重载,改为类似以下形式来更新确认函数以使用std::source_location

inline void confirm (
    bool expected, 
    bool actual,
    const std::source_location location = 
        std::source_location::current())
{
    if (actual != expected)
    {
        throw BoolConfirmException(expected, location.line());
    }
}

我们现在不会因为 bug 而进行这些更改。只要项目中只有一个源文件尝试使用source_location,代码就能正常工作。一旦多个源文件尝试使用source_location,就会产生链接器警告,并且该行方法返回错误数据。这个 bug 最终应该会被修复,我保留这本书中的这一部分,因为它是一个更好的方法。根据你使用的编译器,你现在可能已经开始使用source_location了。

不仅最后一个参数的类型和名称发生了变化,当抛出异常时,传递给异常的行号也需要更改。注意新参数包含一个默认值,该值被设置为当前位置。默认参数值意味着我们不再需要传递行号。新位置将获得一个包含当前行号的默认值。

我们需要在Test.h的顶部包含source_location的头文件,如下所示:

#include <ostream>
#include <source_location>
#include <string_view>
#include <vector>

调用confirm的宏需要更新,不再需要担心行号:

#define CONFIRM_FALSE( actual ) \
    MereTDD::confirm(false, actual)
#define CONFIRM_TRUE( actual ) \
    MereTDD::confirm(true, actual)
#define CONFIRM( expected, actual ) \
    MereTDD::confirm(expected, actual)

一旦source_location正常工作,我们就真的不再需要这些宏了。前两个仍然有用,因为它们消除了指定预期布尔值的需求。此外,所有三个都有点有用,因为它们封装了MereTDD命名空间的指定。尽管从技术上讲我们不需要继续使用宏,但我喜欢继续使用它们,因为我认为全部大写字母的名称有助于使确认在测试中更加突出。

这个改进将非常小,仅限于confirm函数和宏。那么,即使我们目前还不能使用source_location,我们是否仍然应该迁移到 C++ 20 呢?我认为是的。至少,这个 bug 表明标准库总是在不断变化,通常使用最新的编译器和标准库是最好的选择。此外,书中后面将使用到的一些特性只能在 C++20 中找到。例如,我们将使用std::map类和 C++20 中添加的一个有用方法来确定映射是否已包含元素。我们将在第十二章“创建更好的测试确认”中使用概念,这些概念仅在 C++20 中存在。

下一个改进将更加复杂。

探索测试中的 lambda 表达式

开发者避免在代码中使用宏变得越来越普遍。我同意现在几乎不再需要宏。从上一节中的std::source_location来看,使用宏的最后一个理由已经被消除。

一些公司甚至可能在其代码的任何地方都禁止使用宏。我认为这有点过分,尤其是考虑到std::source_location的问题。宏仍然有能力将代码封装起来,以便可以插入而不是使用宏本身。

如前节所示,CONFIRM_TRUECONFIRM_FALSECONFIRM宏可能不再绝对必要。我仍然喜欢它们。但如果你不想使用它们,那么你不必使用——至少在std::source_location在大项目中可靠工作之前。

TESTTEST_EX宏仍然需要,因为它们封装了派生测试类的声明,为它们提供了独特的名称,并设置了代码,以便测试体可以跟随。结果看起来就像我们正在声明一个简单的函数。这是我们想要的效果。测试应该简单易写。我们现在拥有的几乎是最简单的了。但是,设计使用了宏。我们能否做些什么来消除对TESTTEST_EX宏的需求?

无论我们做出什么改变,我们都应该保持Creation.cpp中声明测试的简单性,使其看起来类似于以下内容:

TEST("Test can be created")
{
}

我们真正需要的是一种能够引入测试、给它命名、让测试注册自己,然后让我们编写测试函数体的东西。TEST宏通过隐藏从TestBase类派生的类的全局实例的声明来提供这种能力。这个声明被宏留下未完成,因此我们可以在大括号内提供测试函数体的内容。另一个TEST_EX宏通过捕获传递给宏的异常来做类似的事情。

在 C++中,还有一种编写函数体而不给函数体命名的方法。那就是声明一个lambda。如果我们停止使用TEST宏,并用 lambda 代替实现测试函数,测试会是什么样子?目前,让我们只关注那些不期望抛出异常的测试。以下是一个空测试可能的样子:

Test test123("Test can be created") = [] ()
{
};

通过这个例子,我试图坚持 C++所需的语法。这假设我们有一个名为Test的类,我们想要创建其实例。在这个设计中,测试将重用Test类而不是定义一个新的类。Test类将重写operator =方法以接受 lambda。我们需要给实例一个名字,以便示例使用test123。为什么是test123?好吧,任何创建的对象实例仍然需要一个独特的名称,所以我使用数字来提供一些独特性。如果我们决定使用这个设计,我们可能需要继续使用宏来根据行号生成一个独特的数字。因此,虽然这个设计避免了为每个测试创建一个新的派生类,但它为每个测试创建了一个新的 lambda。

这个想法有一个更大的问题。代码无法编译。可能在函数内部将代码编译成功。但作为一个全局Test实例的声明,我们无法调用赋值运算符。我能想到的最好的办法是将 lambda 放在构造函数内部作为新的参数,如下所示:

Test test123("Test can be created", [] ()
{
});

虽然这对这个测试有效,但当尝试调用setExpectedFailureReason方法时,它会在预期的失败测试中引起问题,因为setExpectedFailureReason不在 lambda 体内部的作用域内。此外,我们离我们现在简单声明测试的方式越来越远。额外的 lambda 语法以及最后的括号和分号使得正确实现这一点变得更加困难。

我至少看到过另一个使用 lambda 表达式并且似乎避免了声明唯一名称的需求的测试库,从而避免了需要使用类似以下内容的宏:

int main ()
{
    Test("Test can be created") = [] ()
    {
    };
    return 0;
};

但实际上,这是调用一个名为Test函数并将字符串字面量作为参数传递。然后,该函数返回一个临时对象,它覆盖了operator=,这是调用 lambda 时调用的。函数只能在其他函数或类方法内部调用。这意味着像这样需要一个解决方案需要在函数内部声明测试,并且测试不能像我们现在这样作为实例全局声明。

通常,这意味着你需要在main函数内部声明所有测试。或者,你可以将测试声明为简单的函数,并在main函数内部调用这些函数。无论哪种方式,你最终都需要修改main来调用每个测试函数。如果你忘记修改main,那么你的测试将不会运行。我们将保持main简单且不杂乱。在我们的解决方案中,main将只执行已注册的测试。

尽管由于增加的复杂性和无法调用测试方法(如setExpectedFailureReason)等问题,lambda 表达式对我们不起作用,但我们仍然可以稍微改进当前的设计。TEST和特别是TEST_EX宏正在执行我们可以从宏中移除的工作。

让我们从修改Test.h中的TestBase类开始,使其注册自身而不是在宏中使用派生类进行注册。此外,我们需要将getTests函数移动到TestBase类之前。我们还需要提前声明TestBase类,因为getTests使用一个指向TestBase的指针,如下所示:

class TestBase;
inline std::vector<TestBase *> & getTests ()
{
    static std::vector<TestBase *> tests;
    return tests;
}
class TestBase
{
public:
    TestBase (std::string_view name)
    : mName(name), mPassed(true), mConfirmLocation(-1)
    {
        getTests().push_back(this);
    }

我们将保持TestBase的其余部分不变,因为它处理诸如名称和测试是否通过等属性。我们仍然有派生类,但这个简化的目标是移除TESTTEST_EX宏需要执行的所有工作。

TEST宏需要做的绝大部分工作是声明一个带有run方法的派生类,该方法将被填充。现在,注册测试的需求由TestBase处理。可以通过创建另一个名为TestExBase的类来进一步简化TEST_EX宏,该类将处理预期的异常。在TestBase之后立即声明这个新类。它看起来像这样:

template <typename ExceptionT>
class TestExBase : public TestBase
{
public:
    TestExBase (std::string_view name,
        std::string_view exceptionName)
    : TestBase(name), mExceptionName(exceptionName)
    { }
    void runEx () override
    {
        try
        {
            run();
        }
        catch (ExceptionT const &)
        {
            return;
        }
        throw MissingException(mExceptionName);
    }
private:
    std::string mExceptionName;
};

TestExBase类从TestBase派生,是一个模板类,旨在捕获预期的异常。此代码目前写入TEST_EX中,我们将更改TEST_EX以使用这个新的基类。

我们准备好简化TESTTEST_EX宏。新的TEST宏看起来像这样:

#define TEST( testName ) \
namespace { \
class MERETDD_CLASS : public MereTDD::TestBase \
{ \
public: \
    MERETDD_CLASS (std::string_view name) \
    : TestBase(name) \
    { } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName); \
void MERETDD_CLASS::run ()

与之前相比,这稍微简单一些。构造函数不再需要在主体中包含代码,因为注册是在基类中完成的。

更大的简化在于TEST_EX宏,看起来像这样:

#define TEST_EX( testName, exceptionType ) \
namespace { \
class MERETDD_CLASS : public MereTDD::TestExBase<exceptionType> \
{ \
public: \
    MERETDD_CLASS (std::string_view name, \
        std::string_view exceptionName) \
    : TestExBase(name, exceptionName) \
    { } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName, #exceptionType); \
void MERETDD_CLASS::run ()

它比之前简单得多,因为所有的异常处理都在其直接基类中完成。注意,当构造实例时,宏仍然需要使用#运算符来指定exceptionType。此外,注意当指定从其派生模板类型时,它使用exceptionType而不使用#运算符。

摘要

本章探讨了利用 C++ 20 中的新功能从标准库而不是从预处理器获取行号来改进测试库的方法。尽管新代码现在不起作用,但它最终将使CONFIRM_TRUECONFIRM_FALSECONFIRM宏成为可选的。您将不再需要使用这些宏。但我仍然喜欢使用它们,因为它们有助于封装容易出错代码。而且,由于它们使用全部大写字母,宏在测试中更容易被发现。

我们还探讨了避免在声明测试时使用宏的趋势,以及如果我们使用 lambda 表达式会是什么样子。这种方法在更复杂的测试声明中几乎可行。然而,额外的复杂性并不重要,因为该设计并不适用于所有测试。

阅读关于提议的更改仍然很有价值。您可以了解其他测试库可能的工作方式,并理解为什么这本书解释了一个采用宏的解决方案。

本章还向您展示了如何在高层次上遵循 TDD(测试驱动开发)流程。在流程中增强测试的步骤可以应用于整体设计。我们能够改进并简化了TESTTEST_EX宏,这使得所有测试都变得更好。

下一章将探讨在测试前后添加代码的需求,以帮助为测试做好准备并在测试完成后清理。

第七章:测试设置和拆卸

你是否曾经在一个项目中工作过,需要首先准备你的工作区域?一旦准备好,你就可以开始工作了。然后,过了一段时间,你需要清理你的区域。也许你会用这个区域做其他事情,不能只是让你的项目闲置在那里,否则会妨碍你。

有时候,测试可能就像那样。它们可能不占用表空间,但有时它们在运行之前可能需要设置环境或准备一些其他结果。也许一个测试确保某些数据可以被删除。数据首先存在是有意义的。测试是否应该负责创建它试图删除的数据?最好是将数据创建封装在其自己的函数中。但如果你需要测试几种不同的删除数据方式呢?每个测试是否都应该创建数据?它们可以调用相同的设置函数。

如果多个测试需要执行类似的前期准备和后期清理工作,不仅将相同的代码写入每个测试是冗余的,而且还会隐藏测试的真实目的。

本章将允许测试运行准备和清理代码,以便它们可以专注于需要测试的内容。准备工作称为设置。清理工作称为拆卸

我们遵循 TDD(测试驱动开发)方法,这意味着我们将从一些简单的测试开始,让它们工作,然后增强它们以实现更多功能。

初始时,我们将让测试运行设置代码,然后在结束时进行拆卸。多个测试可以使用相同的设置和拆卸,但设置和拆卸将针对每个测试单独运行。

一旦这个功能工作,我们将增强设计,让一组测试在测试组之前和之后只运行一次共享的设置和拆卸代码。

在本章中,我们将涵盖以下主要主题:

  • 支持测试设置和拆卸

  • 为多个测试增强测试设置和拆卸

  • 处理设置和拆卸过程中的错误

到本章结束时,测试将能够拥有单独的设置和拆卸代码,以及封装测试组的设置和拆卸代码。

技术要求

本章中的所有代码都使用标准 C++,它基于任何现代 C++ 20 或更高版本的编译器和标准库。代码基于前几章并继续发展。

你可以在以下 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

支持测试设置和拆卸

为了支持测试设置和拆卸,我们只需要安排在测试开始前运行一些代码,并在测试完成后运行一些代码。对于设置,我们可能只需在测试开始附近调用一个函数。设置实际上不必在测试之前运行,只要它在测试需要设置结果之前运行即可。我的意思是,单元测试库实际上不需要在测试开始前运行设置。只要测试本身在测试开始时运行设置,我们就能得到相同的结果。这将是最简单的解决方案。但这并不是一个全新的解决方案。测试已经可以调用其他函数。

我看到简单地声明一个独立函数并在测试开始时调用它的最大问题是意图可能会丢失。我的意思是,测试作者必须确保在测试中调用的函数明确定义为设置函数。因为函数可以有任意的名称,除非有一个好名字,仅仅调用一个函数是不够的,无法识别出设置的意图。

关于拆卸呢?这也可以是一个简单的函数调用吗?因为拆卸代码应该在测试结束时始终运行,测试作者必须确保即使在抛出异常的情况下,拆卸也会运行。

由于这些原因,测试库应该提供一些帮助来进行设置和拆卸。我们需要决定的是帮助的程度以及这种帮助将如何体现。我们的目标是保持测试简单,并确保处理所有边缘情况。

按照在*第三章**中首次解释的 TDD 方法,即《TDD 过程》,我们应该做以下事情:

  • 首先,思考理想解决方案应该是什么。

  • 编写一些使用该解决方案的测试,以确保它将满足我们的期望。

  • 构建项目并修复构建错误,无需担心测试是否通过。

  • 实现一个基本的解决方案,并通过测试。

  • 提高解决方案并改进测试。

帮助设置和拆卸的一个选项可能是向TESTTEST_EX宏添加新参数。这将使设置和拆卸部分成为测试声明的一部分。但这是必要的吗?如果可能,我们应该避免依赖于这些宏。如果可以避免,它们已经足够复杂,无需添加更多功能。修改宏通常不需要用于测试设置和拆卸。

另一个可能的解决方案是在TestBase类中创建一个方法,就像我们在第三章**《TDD 过程》中设置预期失败原因时做的那样。这会起作用吗?为了回答这个问题,让我们思考设置和拆卸代码应该做什么。

设置应该为测试做好准备。这很可能意味着测试将需要引用设置代码准备的数据或资源,例如文件。如果测试得不到可以使用的东西,可能看起来设置并不重要,但谁知道呢?也许设置做了与测试代码无关但未注意到的相关操作。我主要想说的是,设置代码几乎可以执行任何事情。它可能需要自己的参数来定制。或者,它可能能够在没有任何输入的情况下运行。它可能生成测试直接使用的内容。或者,它可能在幕后以对测试有用的方式工作,但对测试代码来说是未知的。

此外,拆解可能需要参考之前设置的内容,以便能够撤销。或者,拆解可能只是清理一切,而不关心它们来自何处。

TestBase中调用方法以注册和运行设置和拆解似乎可能会使与测试代码的交互变得更加复杂,因为我们需要一种方式来共享设置结果。我们真正想要的只是运行设置,获取设置提供的内容,然后在测试结束时运行拆解。有一种简单的方法可以实现这一点,允许设置、拆解和其余测试代码之间所需的任何交互。

让我们从在tests文件夹中创建一个新的.cpp文件开始,命名为Setup.cpp。项目结构将如下所示:

MereTDD project root folder
    Test.h
    tests folder
        main.cpp
        Confirm.cpp
        Creation.cpp
        Setup.cpp

这里有一个在Setup.cpp中的测试,我们可以用它开始:

TEST_EX("Test will run setup and teardown code", int)
{
    int id = createTestEntry();
    // If this was a project test, it might be called
    // "Updating empty name throws". And the type thrown
    // would not be an int.
    updateTestEntryName(id, "");
    deleteTestEntry(id);
}

测试使用三个函数:createTestEntryupdateTestEntryNamedeleteTestEntry。注释解释了测试可能被调用的名称以及如果这是一个实际项目的测试而不是测试库的测试,它将执行的操作。测试的思路是调用createTestEntry来设置一些数据,尝试使用空字符串更新名称以确保不允许这样做,然后调用deleteTestEntry来拆解测试开始时创建的数据。你可以看到设置提供了一个名为id的标识符,这是测试和拆解所需的。

测试期望updateTestEntryName的调用因名称为空而失败,这将导致抛出异常。我们在这里只是抛出一个整数,但在实际项目中,异常类型通常是其他类型。异常将导致跳过拆解调用deleteTestEntry

此外,如果需要,测试可以使用确认来验证其结果。如果确认失败,也会抛出异常。我们需要确保在所有情况下都运行拆解代码。目前,它总是会跳过,因为整个测试的目的就是期望从updateTestEntryName抛出异常。但其他测试如果确认失败,可能仍然会跳过拆解。

即使解决了deleteTestEntry没有被调用的这个问题,测试仍然不够清晰。真正要测试的是什么?在这个测试中,唯一应该突出显示为测试意图的是对updateTestEntryName的调用。对createTestEntrydeleteTestEntry的调用只是隐藏了测试的真实目的。如果我们添加一个try/catch块来确保deleteTestEntry被调用,那么真实目的只会被进一步隐藏。

测试中的三个函数是那种在项目中可能会找到的函数类型。我们没有单独的项目,所以它们可以放在Setup.cpp中,因为它们是我们目的的辅助函数。它们看起来是这样的:

#include "../Test.h"
#include <string_view>
int createTestEntry ()
{
    // If this was real code, it might open a
    // connection to a database, insert a row
    // of data, and return the row identifier.
    return 100;
}
void updateTestEntryName (int /*id*/, std::string_view name)
{
    if (name.empty())
    {
        throw 1;
    }
    // Real code would proceed to update the
    // data with the new name.
}
void deleteTestEntry (int /*id*/)
{
    // Real code would use the id to delete
    // the temporary row of data.
}

id参数名称被注释掉了,因为辅助函数没有使用它们。

我们可以在类的构造函数和析构函数中包装createTestEntrydeleteTestEntry的调用。这有助于简化测试并确保调用拆解代码。新的测试看起来像这样:

TEST_EX("Test will run setup and teardown code", int)
{
    TempEntry entry;
    // If this was a project test, it might be called
    // "Updating empty name throws". And the type thrown
    // would not be an int.
    updateTestEntryName(entry.id(), "");
}

TempEntry类包含设置和拆解调用以及测试和拆解所需的标识符。它可以在三个辅助方法之后直接放入Setup.cpp中:

class TempEntry
{
public:
    TempEntry ()
    {
        mId = createTestEntry();
    }
    ~TempEntry ()
    {
        deleteTestEntry(mId);
    }
    int id ()
    {
        return mId;
    }
private:
    int mId;
};

编写这样的类是确保实例超出作用域时代码被执行的绝佳方式,我们可以用它来确保在测试结束时总是运行拆解代码。它很简单,可以维护自己的状态,例如标识符。此外,它只需要在测试开始时创建一个实例的单行代码,这样就不会分散测试试图做的事情。

当库代码不能满足特定需求时,你可以随时采取这种做法。但是,测试库是否有方法可以使这变得更好?

我见过一些类,允许你将 lambda 或函数传递给构造函数,执行类似操作。构造函数会立即调用第一个函数,并在实例被销毁时调用第二个函数。这就像TempEntry类所做的那样,除了一个细节。TempEntry还管理拆解代码所需的身份。我想到的所有 lambda 解决方案都没有像专门为此目的编写的类(如TempEntry)那样干净。但也许我们还可以再改进一点。

TempEntry的问题在于,它不清楚什么是设置和什么是拆解。在测试中,第一条创建TempEntry类的语句与设置和拆解有什么关系也不清楚。当然,稍微研究一下会让你意识到设置在构造函数中,拆解在析构函数中。如果我们可以有名为setupteardown的方法,并且测试本身清楚地标识了正在运行的设置和拆解代码的使用,那就太好了。

一个想到的解决方案可能是调用虚拟setupteardown方法的基本类。但我们不能使用正常的继承,因为我们需要在构造函数和析构函数中调用它们。相反,我们可以使用一个称为基于策略的设计的设计模式。

一个策略类实现了一个或多个派生类将使用的方法。派生类使用的方法被称为策略。这就像继承的反向。我们将通过如下修改将TempEntry类转变为一个实现setupteardown方法的策略类:

class TempEntry
{
public:
    void setup ()
    {
        mId = createTestEntry();
    }
    void teardown ()
    {
        deleteTestEntry(mId);
    }
    int id ()
    {
        return mId;
    }
private:
    int mId;
};

唯一真正的变化是将构造函数转变为setup方法,将析构函数转变为teardown方法。这仅仅是因为我们之前使用这些方法来做这项工作。现在我们有一个清晰且易于理解的类。但我们如何使用它呢?我们不再有在类构造时自动运行的设置代码和在析构时运行的清理代码。我们需要创建另一个类,这个类可以放在Test.h中,因为它将用于所有测试的设置和清理需求。在Test.hMereTDD命名空间内添加如下模板类:

template <typename T>
class SetupAndTeardown : public T
{
public:
    SetupAndTeardown ()
    {
        T::setup();
    }
    ~SetupAndTeardown ()
    {
        T::teardown();
    }
};

SetupAndTeardown类是我们将setupteardown的调用与构造函数和析构函数重新连接的地方。只要那个类实现了两个setupteardown方法,你可以使用任何你想要的类作为策略。还有一个很好的好处是,由于公有继承,你可以访问策略类中定义的其他方法。我们使用这一点仍然能够调用id方法。基于策略的设计让你能够扩展接口到你需要的样子,只要实现了策略。在这个例子中,策略只是两个setupteardown方法。

使用基于策略的设计,特别是关于继承的问题,还有一个其他方面,那就是这种模式与面向对象设计的“is-a”关系相悖。如果我们以正常的方式使用公有继承,那么我们可以说SetupAndTeardownTempEntry类。在这种情况下,这显然没有意义。没关系,因为我们不会使用这种模式来创建可以互相替换的实例。我们使用公有继承只是为了能够在策略类内部调用诸如id这样的方法。

现在我们已经拥有了所有这些,测试看起来会是什么样子?现在测试可以使用SetupAndTeardown类,如下所示:

TEST_EX("Test will run setup and teardown code", int)
{
    MereTDD::SetupAndTeardown<TempEntry> entry;
    // If this was a project test, it might be called
    // "Updating empty name throws". And the type thrown
    // would not be an int.
    updateTestEntryName(entry.id(), "");
}

这是因为以下列出的原因而有一个很大的改进:

  • 在测试的开始阶段就很清楚,测试代码中附带了设置代码和清理代码。

  • 清理代码将在测试结束时运行,我们不需要通过 try/catch 块来复杂化测试代码。

  • 我们不需要将setupteardown的调用与测试的其他部分混合。

  • 我们可以通过我们编写的诸如id方法这样的方法与设置结果进行交互。

无论何时需要在测试中编写设置和/或拆除代码,你只需要编写一个实现setupteardown方法的类。如果其中一个方法不需要执行任何操作,则可以留空该方法。然而,两个方法都必须存在,因为它们是策略。实现策略方法就是创建策略类。然后,添加一个使用策略类作为模板参数的MereTDD:SetupAndTeardown实例。测试应该在测试开始时声明SetupAndTeardown实例,以便从这种设计中获得最大好处。

虽然我们可以像这样声明在每个测试开始和结束时运行的设置和拆除代码,但我们需要一个不同的解决方案来共享设置和拆除代码,以便设置在测试组之前运行,而拆除代码在测试组完成后运行。下一节将增强设置和拆除功能以满足这一扩展需求。

多个测试的测试设置和拆除增强

现在我们有了为测试设置事物并在测试后清理的能力,我们可以在设置中准备测试所需的临时数据,以便运行测试,然后在测试运行后拆除设置中的临时数据。如果有许多不同的测试使用此类数据,它们可以各自创建类似的数据。

但如果我们需要为整个测试组设置某些东西,然后在所有测试完成后将其拆除呢?我指的是在多个测试中保持不变的东西。对于临时数据,可能我们需要准备一个地方来存储数据。如果数据存储在数据库中,那么现在是打开数据库并确保必要的表已经准备好以存储每个测试将创建的数据的好时机。甚至数据库连接本身也可以保持打开状态,供测试使用。一旦所有数据测试完成,那么拆除代码就可以关闭数据库。

这种场景适用于许多不同的情况。如果你正在测试与硬盘上的文件相关的东西,那么你可能想确保适当的目录已经准备好,以便创建文件。这些目录可以在任何文件测试开始之前设置,而测试只需要担心创建它们将要测试的文件。

如果你正在测试一个网络服务,那么在测试开始前确保测试有一个有效的和经过身份验证的登录可能是有意义的。可能没有必要让每个测试每次都重复登录步骤。除非,当然,这是测试的目的。

这里的主要思想是,虽然让一些代码在每个测试中作为设置和拆除运行是好的,但也可以让不同的设置和拆除代码只为测试组运行一次。这正是本节将要探讨的内容。

我们将称由共同的设置和拆解代码关联的一组测试为测试套件。测试不必属于测试套件,但我们将创建一个内部和隐藏的测试套件来分组所有没有特定套件的单独测试。

我们能够在单个测试中完全添加设置和拆解代码,因为测试中的设置和拆解代码就像调用几个函数一样。然而,为了支持测试套件的设置和拆解,我们可能需要在测试之外做一些工作。我们需要确保设置代码在相关测试运行之前运行。然后,在所有相关测试完成后运行拆解代码。

包含并运行所有测试的测试项目应该能够支持多个测试套件。这意味着测试需要一种方式来识别它所属的测试套件。此外,我们还需要一种方式来声明测试套件的设置和拆解代码。

这个想法是这样的:我们将声明并编写一些代码作为测试套件的设置。或者,如果我们只需要拆解代码,也许我们可以让设置代码是可选的。然后,我们将声明并编写一些拆解代码。拆解代码也应该可选。无论是设置、拆解,还是两者都要定义,才能有一个有效的测试套件。每个测试都需要一种方式来识别它所属的测试套件。当运行项目中的所有测试时,我们需要按照正确的顺序运行它们,以便测试套件设置首先运行,然后是测试套件中的所有测试,最后是测试套件的拆解。

我们将如何识别测试套件?测试库会自动为每个测试生成唯一的名称,并且这些名称对测试作者来说是隐藏的。我们也可以为测试套件使用名称,但让测试作者指定每个测试套件的名称。这似乎是可理解的,并且应该足够灵活以处理任何情况。我们将让测试作者为每个测试套件提供一个简单的字符串名称。

当处理名称时,一个经常出现的边缘情况是处理重复名称的问题。我们需要做出决定。我们可以检测重复名称并停止测试以显示错误,或者我们可以堆叠设置和拆解,以便它们都运行。

我们是否在单个测试的设置和拆解中遇到了这个问题?实际上并没有,因为设置和拆解没有命名。但是,如果一个测试声明了多个SetupAndTeardown实例会发生什么?我们实际上在前一节中没有考虑这种可能性。在一个测试中,它可能看起来像这样:

TEST("Test will run multiple setup and teardown code")
{
    MereTDD::SetupAndTeardown<TempEntry> entry1;
    MereTDD::SetupAndTeardown<TempEntry> entry2;
    // If this was a project test, it might need
    // more than one temporary entry. The TempEntry
    // policy could either create multiple data records
    // or it is easier to just have multiple instances
    // that each create a single data entry.
    updateTestEntryName(entry1.id(), "abc");
    updateTestEntryName(entry2.id(), "def");
}

拥有多个设置和清理实例的能力是很有趣的,这应该有助于简化并允许你重用设置和清理代码。而不是创建执行许多操作的特定设置和清理策略类,这将允许它们堆叠起来,以便更加专注。也许一个测试只需要在最后设置和清理单个数据集,而另一个测试则需要两个。而不是创建两个不同的策略类,这种能力将允许第一个测试声明一个单独的 SetupAndTeardown 实例,而第二个测试通过声明两个来重用相同的策略类。

既然我们现在允许单个测试设置和清理代码的组合,为什么不允许测试套件的设置和清理代码的组合呢?这似乎是合理的,甚至可能简化测试库代码。这是如何实现的呢?

好吧,既然我们已经了解了这种能力,我们就可以为此进行规划,并可能避免编写检测和抛出错误的代码。如果我们注意到两个或更多具有相同名称的测试套件设置定义,我们可以将它们添加到集合中,而不是将这种情况视为一个特殊错误情况。

如果我们确实有多个具有相同名称的设置和清理定义,我们就不应该依赖于它们之间的任何特定顺序。它们可以像测试一样被分割到不同的 .cpp 文件中。这将简化代码,因为我们可以在找到它们时将它们添加到集合中,而不用担心特定的顺序。

下一步要考虑的是如何定义测试套件的设置和清理代码。它们可能不能是简单的函数,因为它们需要与测试库进行注册。注册是必要的,这样当测试提供一个套件名称时,我们将知道这个名称的含义。注册看起来与测试进行自我注册的方式非常相似。我们应该能够为套件名称添加一个额外的字符串。此外,即使测试不是特定测试套件的一部分,它们也需要这个新的套件名称。我们将使用空套件名称为想要在测试套件之外运行的测试。

注册时需要让测试自己使用套件名称进行注册,即使该套件名称的设置和清理代码尚未注册。这是因为测试可以定义在多个 .cpp 文件中,我们无法知道初始化代码将按什么顺序注册测试和测试套件的设置和清理代码。

还有一个更重要的要求。我们有一种方式可以与单个测试设置和清理代码中的设置结果进行交互。在测试套件设置和清理中,我们也将需要这种能力。假设测试套件设置需要打开一个数据库连接,该连接将被套件中的所有测试使用。测试需要某种方式来了解这个连接。此外,如果测试套件清理想要关闭连接,它也需要了解这个连接。也许测试套件设置还需要创建一个数据库表。测试将需要该表的名字以便使用它。

让我们在Setup.cpp中创建几个辅助函数,以模拟创建和删除表的操作。它们应该看起来像这样:

#include <string>
#include <string_view>
std::string createTestTable ()
{
    // If this was real code, it might open a
    // connection to a database, create a temp
    // table with a random name, and return the
    // table name.
    return "test_data_01";
}
void dropTestTable (std::string_view /*name*/)
{
    // Real code would use the name to drop
    // the table.
}

然后,在Setup.cpp文件中,我们可以使我们的第一个测试套件设置和清理看起来像这样:

class TempTable
{
public:
    void setup ()
    {
        mName = createTestTable();
    }
    void teardown ()
    {
        dropTestTable(mName);
    }
    std::string tableName ()
    {
        return mName;
    }
private:
    std::string mName;
};

这看起来非常像上一节中用来定义setupteardown方法以及提供访问由设置代码提供的任何额外方法或数据的策略类。这是因为这也将是一个策略类。我们不妨让策略保持一致,无论设置和清理代码是用于单个测试还是整个测试套件。

当我们声明一个测试只有设置和清理代码时,我们声明了一个使用策略类的特化MereTDD::SetupAndTeardown的实例。这足以立即运行设置代码并确保在测试结束时运行清理代码。但为了获取其他信息,给SetupAndTeardown实例一个名字是很重要的。设置和清理代码完全定义并通过本地命名实例可访问。

然而,在测试套件设置和清理中,我们需要将策略类的实例放入容器中。容器希望它内部的所有内容都是单一类型。设置和清理实例不能再是测试中的简单本地命名变量。然而,我们仍然需要一个命名类型,因为这是测试套件中的测试访问设置代码提供的资源的方式。

我们需要弄清楚两件事。第一是创建测试套件设置和清理代码实例的位置。第二是如何协调容器需要所有内容都是单一类型的需求与测试能够引用特定类型的命名实例的需求,这些实例可能因策略类而异。

第一个问题最容易解决,因为我们需要考虑生命周期和可访问性。测试套件的设置和销毁实例需要在测试套件内的多个测试中存在并有效。它们不能作为单个测试中的局部变量存在。它们需要在一个将保持对多个测试有效的地方。它们可以是main内部的局部实例——这将解决生命周期问题。但这样它们就只能被main访问。测试套件的设置和销毁实例需要是全局的。只有这样,它们才能在整个测试应用程序期间存在,并且可以被多个测试访问。

对于第二个问题,我们首先将声明一个接口,该接口将用于存储所有测试套件的设置和销毁实例。测试库在需要运行设置和销毁代码时也将使用此相同的接口。测试库需要将所有内容视为相同,因为它对特定的策略类一无所知。

我们稍后会回到所有这些。在我们走得太远之前,我们需要考虑我们的预期使用方式。我们仍然遵循 TDD 方法,虽然考虑所有需求和可能的情况是好的,但我们已经足够深入,可以有一个关于测试套件设置和销毁使用的良好想法。我们甚至已经有了准备好的策略类和定义。将以下内容添加到Setup.cpp中,作为我们将要实现的预期使用:

MereTDD::TestSuiteSetupAndTeardown<TempTable>
gTable1("Test suite setup/teardown 1", "Suite 1");
MereTDD::TestSuiteSetupAndTeardown<TempTable>
gTable2("Test suite setup/teardown 2", "Suite 1");
TEST_SUITE("Test part 1 of suite", "Suite 1")
{
    // If this was a project test, it could use
    // the table names from gTable1 and gTable2.
    CONFIRM("test_data_01", gTable1.tableName());
    CONFIRM("test_data_01", gTable2.tableName());
}
TEST_SUITE_EX("Test part 2 of suite", "Suite 1", int)
{
    // If this was a project test, it could use
    // the table names from gTable1 and gTable2.
    throw 1;
}

有几点需要通过前面的代码进行解释。你可以看到它声明了两个MereTDD::TestSuiteSetupAndTeardown的实例,每个实例都专门使用TempTable策略类。这些是具有特定类型的全局变量,因此测试将能够看到它们并使用策略类中的方法。如果你想的话,可以为每个实例使用不同的策略类。或者,如果你使用相同的策略类,那么通常应该有一些差异。否则,为什么有两个实例?对于创建临时表,正如这个示例所示,每个表可能都有一个唯一的随机名称,并且能够使用相同的策略类。

构造函数需要两个字符串。第一个是设置和销毁代码的名称。我们将测试套件的设置和销毁代码视为一个测试本身。我们将测试套件的设置和销毁通过或失败的结果包含在测试应用程序摘要中,并用构造函数提供的名称来标识它。第二个字符串是测试套件的名称。这可以是任何内容,但不能是空字符串。我们将空测试套件名称视为不属于任何测试套件的测试的特殊值。

在这个示例中,TestSuiteSetupAndTeardown的两个实例使用相同的套件名称。这是可以接受的,也是支持的,因为我们之前决定。任何有多个具有相同名称的测试套件设置和销毁实例时,它们都将运行在测试套件开始之前。

为什么使用新的TestSuiteSetupAndTeardown测试库类而不是重用现有的SetupAndTeardown类将在稍后变得清晰。它需要合并一个通用接口与策略类。新的类也清楚地表明,这个设置和清理是为测试套件而设的。

然后是测试。我们需要一个新的宏TEST_SUITE,以便可以指定测试套件名称。除了测试套件名称外,该宏的行为几乎与现有的TEST宏相同。我们还需要一个新的宏来表示属于测试套件且期望异常的测试。我们将称其为TEST_SUITE_EX;它的行为类似于TEST_EX,但增加了测试套件名称。

Test.h中需要进行许多更改以支持测试套件。大多数更改都与测试的注册和运行方式相关。我们有一个名为TestBase的测试基类,它通过将TestBase指针推送到向量中来执行注册。由于我们还需要注册测试套件的设置和清理代码,并按测试套件分组运行测试,因此我们需要对此进行更改。我们将保持TestBase作为所有测试的基类。但现在它也将成为测试套件的基类。

测试集合需要更改为映射,以便可以通过测试套件名称访问测试。没有测试套件的测试仍然有一个套件名称。它将只是空的。此外,我们还需要通过套件名称查找测试套件的设置和清理代码。我们需要两个集合:一个映射用于测试,一个映射用于测试套件的设置和清理代码。由于我们需要将现有的注册代码从TestBase中重构出来,我们将创建一个名为Test的类,用于测试,以及一个名为TestSuite的类,用于测试套件的设置和清理代码。TestTestSuite类都将从TestBase派生。

将使用现有的getTests函数来访问映射,该函数将被修改为使用映射并添加一个新的getTestSuites函数。首先,在Test.h的顶部包含一个映射:

#include <map>
#include <ostream>
#include <string_view>
#include <vector>

然后,在更下面,将前向声明TestBase类和实现getTests函数的部分修改如下:

class Test;
class TestSuite;
inline std::map<std::string, std::vector<Test *>> & getTests ()
{
    static std::map<std::string, std::vector<Test *>> tests;
    return tests;
}
inline std::map<std::string, std::vector<TestSuite *>> & getTestSuites ()
{
    static std::map<std::string,            std::vector<TestSuite *>> suites;
    return suites;
}

每个映射的键将是测试套件名称的字符串。值将是TestTestSuite指针的向量。当我们注册测试或测试套件设置和清理代码时,我们将通过测试套件名称进行注册。对于任何测试套件名称的第一个注册,需要设置一个空向量。一旦向量已经设置,测试就可以像之前一样推送到向量的末尾。测试套件的设置和清理代码也将执行相同操作。为了使这个过程更容易,我们将在Test.hgetTestSuites函数之后创建几个辅助方法:

inline void addTest (std::string_view suiteName, Test * test)
{
    std::string name(suiteName);
    if (not getTests().contains(name))
    {
        getTests().try_emplace(name, std::vector<Test *>());
    }
    getTests()[name].push_back(test);
}
inline void addTestSuite (std::string_view suiteName, TestSuite * suite)
{
    std::string name(suiteName);
    if (not getTestSuites().contains(name))
    {
        getTestSuites().try_emplace(name,            std::vector<TestSuite *>());
    }
    getTestSuites()[name].push_back(suite);
}

接下来是重构后的 TestBase 类,它已经被修改以添加测试套件名称,停止进行测试注册,移除预期失败原因,并移除运行代码。现在 TestBase 类将只包含测试和测试套件设置和清理代码之间的公共数据。修改后的类如下:

class TestBase
{
public:
    TestBase (std::string_view name, std::string_view suiteName)
    : mName(name),
      mSuiteName(suiteName),
      mPassed(true),
      mConfirmLocation(-1)
    { }
    virtual ~TestBase () = default;
    std::string_view name () const
    {
        return mName;
    }
    std::string_view suiteName () const
    {
        return mSuiteName;
    }
    bool passed () const
    {
        return mPassed;
    }
    std::string_view reason () const
    {
        return mReason;
    }
    int confirmLocation () const
    {
        return mConfirmLocation;
    }
    void setFailed (std::string_view reason,          int confirmLocation = -1)
    {
        mPassed = false;
        mReason = reason;
        mConfirmLocation = confirmLocation;
    }
private:
    std::string mName;
    std::string mSuiteName;
    bool mPassed;
    std::string mReason;
    int mConfirmLocation;
};

从之前的 TestBase 类中提取的功能现在进入了一个新的派生类,称为 Test,看起来是这样的:

class Test : public TestBase
{
public:
    Test (std::string_view name, std::string_view suiteName)
    : TestBase(name, suiteName)
    {
        addTest(suiteName, this);
    }
    virtual void runEx ()
    {
        run();
    }
    virtual void run () = 0;
    std::string_view expectedReason () const
    {
        return mExpectedReason;
    }
    void setExpectedFailureReason (std::string_view reason)
    {
        mExpectedReason = reason;
    }
private:
    std::string mExpectedReason;
};

Test 类更短,因为现在很多基本信息都存储在 TestBase 类中。此外,我们曾经有一个 TestExBase 类,需要稍作修改。现在它将被称为 TestEx,看起来是这样的:

template <typename ExceptionT>
class TestEx : public Test
{
public:
    TestEx (std::string_view name,
        std::string_view suiteName,
        std::string_view exceptionName)
    : Test(name, suiteName), mExceptionName(exceptionName)
    { }
    void runEx () override
    {
        try
        {
            run();
        }
        catch (ExceptionT const &)
        {
            return;
        }
        throw MissingException(mExceptionName);
    }
private:
    std::string mExceptionName;
};

TestEx 类真正改变的是名称和基类名称。

现在,我们可以进入新的 TestSuite 类。这将是一个将被存储在映射中并作为测试库运行设置和清理代码的公共接口的通用接口。

这个类看起来是这样的:

class TestSuite : public TestBase
{
public:
    TestSuite (
        std::string_view name,
        std::string_view suiteName)
    : TestBase(name, suiteName)
    {
        addTestSuite(suiteName, this);
    }
    virtual void suiteSetup () = 0;
    virtual void suiteTeardown () = 0;
};

TestSuite 类没有像 Test 类那样的 runEx 方法。测试套件的存在是为了分组测试并为测试提供一个使用环境,因此编写预期会抛出异常的设置代码是没有意义的。测试套件的存在不是为了测试任何东西。它存在是为了准备一个或多个将使用 suiteSetup 方法准备好的资源的测试。同样,清理代码也不打算测试任何东西。suiteTeardown 代码只是用来清理设置的内容。如果在测试套件设置和清理过程中发生任何异常,我们希望知道它们。

此外,TestSuite 类没有像 Test 类那样的 run 方法,因为我们需要明确区分设置和清理。没有单一的代码块可以运行。现在有两个独立的代码块需要运行,一个在设置时运行,一个在清理时运行。因此,虽然 Test 类的设计是用来运行某些内容的,但 TestSuite 类的设计是用来准备一组测试,通过设置来准备环境,然后在测试后通过清理来清理环境。

你可以看到,TestSuite 构造函数通过调用 addTestSuite 来注册测试套件的设置和清理代码。

我们有一个名为 runTests 的函数,它目前遍历所有测试并运行它们。如果我们把运行单个测试的代码放在一个新的函数中,我们可以简化遍历所有测试并显示总结的代码。这将是重要的,因为在新设计中我们需要运行更多的测试。我们还需要运行测试套件的设置和清理代码。

这里有一个辅助函数来运行单个测试:

inline void runTest (std::ostream & output, Test * test,
    int & numPassed, int & numFailed, int & numMissedFailed)
{
    output << "------- Test: "
        << test->name()
        << std::endl;
    try
    {
        test->runEx();
    }
    catch (ConfirmException const & ex)
    {
        test->setFailed(ex.reason(), ex.line());
    }
    catch (MissingException const & ex)
    {
        std::string message = "Expected exception type ";
        message += ex.exType();
        message += " was not thrown.";
        test->setFailed(message);
    }
    catch (...)
    {
        test->setFailed("Unexpected exception thrown.");
    }
    if (test->passed())
    {
        if (not test->expectedReason().empty())
        {
            // This test passed but it was supposed
            // to have failed.
            ++numMissedFailed;
            output << "Missed expected failure\n"
                << "Test passed but was expected to fail."
                << std::endl;
        }
        else
        {
            ++numPassed;
            output << "Passed"
                << std::endl;
        }
    }
    else if (not test->expectedReason().empty() &&
        test->expectedReason() == test->reason())
    {
        ++numPassed;
        output << "Expected failure\n"
            << test->reason()
            << std::endl;
    }
    else
    {
        ++numFailed;
        if (test->confirmLocation() != -1)
        {
            output << "Failed confirm on line "
                << test->confirmLocation() << "\n";
        }
        else
        {
            output << "Failed\n";
        }
        output << test->reason()
            << std::endl;
    }
}

上述代码几乎与runTests中的代码相同。对测试名称显示的开始输出进行了一些细微的更改,这是为了帮助区分测试与设置和清理代码。辅助函数还接收记录计数器的引用。

我们可以创建另一个辅助函数来运行设置和清理代码。此函数将执行几乎相同的设置和清理步骤。主要区别在于调用TestSuite指针的方法,即suiteSetupsuiteTeardown。辅助函数看起来如下:

inline bool runSuite (std::ostream & output,
    bool setup, std::string const & name,
    int & numPassed, int & numFailed)
{
    for (auto & suite: getTestSuites()[name])
    {
        if (setup)
        {
            output << "------- Setup: ";
        }
        else
        {
            output << "------- Teardown: ";
        }
        output << suite->name()
            << std::endl;
        try
        {
            if (setup)
            {
                suite->suiteSetup();
            }
            else
            {
                suite->suiteTeardown();
            }
        }
        catch (ConfirmException const & ex)
        {
            suite->setFailed(ex.reason(), ex.line());
        }
        catch (...)
        {
            suite->setFailed("Unexpected exception thrown.");
        }
        if (suite->passed())
        {
            ++numPassed;
            output << "Passed"
                << std::endl;
        }
        else
        {
            ++numFailed;
            if (suite->confirmLocation() != -1)
            {
                output << "Failed confirm on line "
                    << suite->confirmLocation() << "\n";
            }
            else
            {
                output << "Failed\n";
            }
            output << suite->reason()
                << std::endl;
            return false;
        }
    }
    return true;
}

此函数比运行测试的辅助函数稍微简单一些。那是因为我们不需要担心遗漏的异常或预期的失败。它几乎做了同样的事情。它尝试运行设置或清理,捕获异常,并更新通过或失败的计数。

我们可以在runTests函数内部使用两个辅助函数runTestrunSuite,该函数需要按照以下方式修改:

inline int runTests (std::ostream & output)
{
    output << "Running "
        << getTests().size()
        << " test suites\n";
    int numPassed = 0;
    int numMissedFailed = 0;
    int numFailed = 0;
    for (auto const & [key, value]: getTests())
    {
        std::string suiteDisplayName = "Suite: ";
        if (key.empty())
        {
            suiteDisplayName += "Single Tests";
        }
        else
        {
            suiteDisplayName += key;
        }
        output << "--------------- "
            << suiteDisplayName
            << std::endl;
        if (not key.empty())
        {
            if (not getTestSuites().contains(key))
            {
                output << "Test suite is not found."
                    << " Exiting test application."
                    << std::endl;
                return ++numFailed;
            }
            if (not runSuite(output, true, key,
                numPassed, numFailed))
            {
                output << "Test suite setup failed."
                    << " Skipping tests in suite."
                    << std::endl;
                continue;
            }
        }
        for (auto * test: value)
        {
            runTest(output, test,
                numPassed, numFailed, numMissedFailed);
        }
        if (not key.empty())
        {
            if (not runSuite(output, false, key,
                numPassed, numFailed))
            {
                output << "Test suite teardown failed."
                    << std::endl;
            }
        }
    }
    output << "-----------------------------------\n";
    output << "Tests passed: " << numPassed
        << "\nTests failed: " << numFailed;
    if (numMissedFailed != 0)
    {
        output << "\nTests failures missed: "                << numMissedFailed;
    }
    output << std::endl;
    return numFailed;
}

显示的初始语句显示了正在运行的测试套件数量。为什么代码查看测试的大小而不是测试套件的大小?嗯,那是因为测试包括了所有内容,包括有测试套件的测试以及在没有测试套件的测试,这些测试在名为Single Tests的虚构套件下运行。

此函数中的主要循环检查测试映射中的每个项目。之前,这些是测试的指针。现在每个条目都是一个测试套件名称和测试指针的向量。这使得我们可以遍历每个测试所属的测试套件已经分组的测试。空测试套件名称代表没有测试套件的单个测试。

如果我们找到一个非空测试套件,那么我们需要确保至少有一个条目与测试套件的名称匹配。如果没有,那么这是测试项目中的错误,并且不会运行进一步的测试。

如果测试项目注册了一个带有套件名称的测试,那么它还必须为该套件注册设置和清理代码。假设我们已经有该套件的设置和清理代码,每个注册的设置都会运行并检查是否有错误。如果设置测试套件时出现错误,则只会跳过该套件中的测试。

一旦运行完所有测试套件的设置代码,那么就会为该套件运行测试。

在运行完套件的所有测试之后,然后运行所有测试套件的清理代码。

要启用所有这些功能,还有两个部分。第一部分是TestSuiteSetupAndTeardown类,它位于Test.h中,紧接现有的SetupAndTeardown类之后。它看起来如下:

template <typename T>
class TestSuiteSetupAndTeardown :
    public T,
    public TestSuite
{
public:
    TestSuiteSetupAndTeardown (
        std::string_view name,
        std::string_view suite)
    : TestSuite(name, suite)
    { }
    void suiteSetup () override
    {
        T::setup();
    }
    void suiteTeardown () override
    {
        T::teardown();
    }
};

这是一个在测试.cpp文件中使用,用于声明具有特定策略类的测试套件设置和清理实例的类。这个类使用多重继承来连接策略类和常见的TestSuite接口类。当runSuite函数通过指向TestSuite的指针调用suiteSetupsuiteTeardown时,这些虚拟方法最终会调用这个类中的重写方法。每个方法只是调用策略类中的setupteardown方法来完成实际工作。

需要解释的最后一种更改是宏。我们需要两个额外的宏来声明一个属于测试套件但没有预期异常和有预期异常的测试。这些宏被称为TEST_SUITETEST_SUITE_EX。由于TestBase类的重构,现有的TESTTEST_EX宏需要做些小的修改。现有的宏需要更新,以使用新的TestTestEx类而不是TestBaseTestExBase。此外,现有的宏现在需要传递一个空字符串作为测试套件名称。我将在这里展示新的宏,因为它们非常相似,除了测试套件名称的不同。TEST_SUITE宏看起来是这样的:

#define TEST_SUITE( testName, suiteName ) \
namespace { \
class MERETDD_CLASS : public MereTDD::Test \
{ \
public: \
    MERETDD_CLASS (std::string_view name, \
      std::string_view suite) \
    : Test(name, suite) \
    { } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName, suiteName); \
void MERETDD_CLASS::run ()

现在的宏接受一个suiteName参数,该参数作为套件名称传递给实例。而TEST_SUITE_EX宏看起来是这样的:

#define TEST_SUITE_EX( testName, suiteName, exceptionType ) \
namespace { \
class MERETDD_CLASS : public MereTDD::TestEx<exceptionType> \
{ \
public: \
    MERETDD_CLASS (std::string_view name, \
        std::string_view suite, \
        std::string_view exceptionName) \
    : TestEx(name, suite, exceptionName) \
    { } \
    void run () override; \
}; \
} /* end of unnamed namespace */ \
MERETDD_CLASS MERETDD_INSTANCE(testName, suiteName, #exceptionType); \
void MERETDD_CLASS::run ()

新的套件宏与修改后的非套件宏非常相似,所以我尝试将非套件宏改为使用空套件名称调用套件宏。但我无法找出如何将空字符串传递给另一个宏。这些宏很短,所以我保留了它们相似的代码。

这就是启用测试套件所需的所有更改。这些更改后,总结输出看起来略有不同。构建和运行测试项目会产生以下输出。因为它现在有 30 个测试,所以输出有点长。因此,我不会显示整个输出。第一部分看起来是这样的:

Running 2 test suites
--------------- Suite: Single Tests
------- Test: Test will run setup and teardown code
Passed
------- Test: Test will run multiple setup and teardown code
Passed
------- Test: Test can be created
Passed
------- Test: Test that throws unexpectedly can be created
Expected failure
Unexpected exception thrown.

在这里,你可以看到有两个测试套件。一个是名为Suite 1的套件,包含两个测试以及套件设置和清理,另一个是无名的,包含所有不属于测试套件的其它测试。输出的一部分恰好是单个测试。总结输出的其余部分显示了测试套件,看起来是这样的:

--------------- Suite: Suite 1
------- Setup: Test suite setup/teardown 1
Passed
------- Setup: Test suite setup/teardown 2
Passed
------- Test: Test part 1 of suite
Passed
------- Test: Test part 2 of suite
Passed
------- Teardown: Test suite setup/teardown 1
Passed
------- Teardown: Test suite setup/teardown 2
Passed
-----------------------------------
Tests passed: 30
Tests failed: 0
Tests failures missed: 1

每个测试套件都在总结输出中以套件名称开头,后面跟着该套件中的所有测试。对于实际的套件,你可以看到围绕所有测试的设置和清理。每个设置和清理都像测试一样运行。

最后,它显示的通过和失败计数与之前一样。

在本节中,我简要地解释了一些设置和清理代码的错误处理,但还需要更多。本节的主要目的是让设置和清理代码为测试套件工作。其中一部分需要一些错误处理,比如当测试声明它属于一个不存在的套件时应该怎么做。下一节将更深入地探讨这个问题。

处理设置和清理过程中的错误

错误可以在代码的任何地方找到,包括设置和清理代码中。那么,应该如何处理这些错误呢?在本节中,您将看到处理设置和清理代码中的错误没有唯一的方法。更重要的是,您应该意识到后果,以便您可以编写更好的测试。

让我们从开始的地方开始。我们已经避开了与多个设置和清理声明相关的一类问题。我们决定简单地允许这些声明而不是试图阻止它们。因此,一个测试可以有任意多的设置和清理声明。此外,测试套件也可以声明任意多的设置和清理实例。

然而,尽管允许了多个实例,但这并不意味着不会有问题。创建测试数据条目的代码就是一个很好的例子。我考虑在代码中修复这个问题,但留了下来,以便在这里解释问题:

int createTestEntry ()
{
    // If this was real code, it might open a
    // connection to a database, insert a row
    // of data, and return the row identifier.
    return 100;
}

问题在先前的注释中有所暗示。它提到真正的代码会返回行标识符。由于这是一个与实际数据库没有关联的测试辅助函数,它只是简单地返回一个常量值 100。

您希望避免设置代码执行任何可能与其他设置代码冲突的操作。数据库中的行标识符不会冲突,因为每次插入数据时数据库都会返回不同的 ID。但是,其他在数据中填充的字段呢?例如,您可能在表中设置了约束,其中名称必须是唯一的。如果您在一个设置中创建了一个固定的测试名称,那么您将无法在另一个设置中使用相同的名称。

即使您在不同的设置块中有不同的固定名称,它们不会引起冲突,但如果测试数据没有得到适当的清理,您仍然会遇到问题。您可能会发现第一次运行测试时一切正常,然后之后失败,因为固定的名称已经在数据库中存在。

我建议您随机化您的测试数据。以下是一个创建测试表的另一个示例:

std::string createTestTable ()
{
    // If this was real code, it might open a
    // connection to a database, create a temp
    // table with a random name, and return the
    // table name.
    return "test_data_01";
}

先前代码中的注释也提到了创建一个随机名称。使用固定的前缀是可以的,但考虑将末尾的数字设置为随机而不是固定的。这不会完全解决数据冲突的问题。随机数字可能会变成相同的。但是,与良好的测试数据清理一起做,应该有助于消除大多数冲突设置的情况。

另一个问题已经在测试库代码中得到了处理。那就是当测试声明它属于某个测试套件,而该测试套件没有定义任何设置和拆卸代码时应该怎么做。

这在测试应用程序本身中被视为一个致命错误。一旦找不到所需的测试套件设置和拆卸注册,测试应用程序将退出并且不再运行任何更多测试。

修复很简单。确保为所有测试使用的测试套件始终定义测试套件设置和拆卸代码。即使注册的测试套件设置和拆卸代码从未被任何测试使用,这也是可以的。但是,一旦测试声明它属于某个测试套件,那么该套件就变得是必需的。

现在,让我们谈谈设置和拆卸代码中的异常。这包括确认,因为失败的CONFIRM宏会导致抛出异常。将确认添加到如下设置代码中是可以的:

class TempEntry
{
public:
    void setup ()
    {
        mId = createTestEntry();
        CONFIRM(10, mId);
    }

目前,这会导致设置失败,因为身份被固定为始终是 100 的值。而确认尝试确保该值为 10。由于测试设置代码被调用时就像是一个常规函数调用,这次失败的确认结果将与测试本身中任何其他失败的确认相同。测试将失败,总结将显示失败发生的位置和原因。总结看起来像这样:

------- Test: Test will run multiple setup and teardown code
Failed confirm on line 51
    Expected: 10
    Actual  : 100

然而,将确认放入拆卸代码是不推荐的。从拆卸代码中抛出异常也是不推荐的——特别是对于测试拆卸代码,因为测试拆卸代码是在析构函数内部运行的。所以,将确认移动到如下拆卸代码中不会以相同的方式工作:

class TempEntry
{
public:
    void setup ()
    {
        mId = createTestEntry();
    }
    void teardown ()
    {
        deleteTestEntry(mId);
        CONFIRM(10, mId);
    }

这将导致在使用TempEntry策略类SetupAndTeardown类析构时抛出异常。整个测试应用程序将像这样终止:

Running 2 test suites
--------------- Suite: Single Tests
------- Test: Test will run setup and teardown code
terminate called after throwing an instance of 'MereTDD::ActualConfirmException'
/tmp/codelite-exec.sh: line 3: 38155 Abort trap: 6           ${command}

在测试套件拆卸代码中,问题并不那么严重,因为该拆卸代码是在套件中的所有测试完成后由测试库运行的。它不是作为类析构函数的一部分运行的。仍然建议在拆卸代码中不要抛出任何异常。

将你的拆卸代码视为清理设置和测试留下的混乱的机会。通常,它不应该包含任何需要测试的内容。

测试套件设置代码与测试设置代码略有不同。虽然测试设置代码中的异常会导致测试停止运行并失败,但在测试套件设置中抛出的异常会导致该套件中的所有测试被跳过。将此确认添加到测试套件设置将触发异常:

class TempTable
{
public:
    void setup ()
    {
        mName = createTestTable();
        CONFIRM("test_data_02", mName);
    }

并且输出总结显示整个测试套件像这样被干扰:

--------------- Suite: Suite 1
------- Setup: Test suite setup/teardown 1
Failed confirm on line 73
    Expected: test_data_02
    Actual  : test_data_01
Test suite setup failed. Skipping tests in suite.

前面的消息表示测试套件将被跳过。

在测试库中为测试设置和清理以及测试套件设置和清理所进行的所有错误处理在很大程度上都没有经过测试。我的意思是,我们为测试库添加了一个额外的功能来支持任何预期的失败。我没有为设置和清理代码中的预期失败做同样的事情。我觉得处理设置和清理代码中预期失败所需的额外复杂性不值得其带来的好处。

我们使用 TDD(测试驱动开发)来指导软件的设计并提高软件的质量。但 TDD 并不能完全消除对某些边缘条件的手动测试需求,这些条件在自动化测试中太难测试,或者根本不可行。

那么,是否会有一个测试来确保当所需的测试套件未注册时,测试库确实会终止?不会。这似乎是最好通过手动测试来处理的那种测试。你可能会遇到类似的情况,你将不得不决定编写测试所需的努力程度,以及这种努力是否值得成本。

摘要

本章完成了单元测试库中所需的最小功能。我们还没有完成测试库的开发,但现在它已经具有足够的功能,可以用于其他项目。

你已经了解了添加设置和清理代码所涉及的问题以及提供的优势。主要优势是,测试现在可以专注于需要测试的重要部分。当不再有杂乱无章的测试代码和自动处理的清理时,测试更容易理解。

设置和清理有两种类型。一种是局部于测试的;它可以在其他测试中重用,但局部意味着设置在测试开始时运行,清理在测试结束时发生。另一个共享相同设置和清理的测试将重复在该其他测试中的设置和清理。

另一种类型的设置和清理实际上是由多个测试共享的。这是测试套件的设置和清理;它的设置在套件中的任何测试开始之前运行,它的清理在套件中的所有测试完成后运行。

对于局部测试,我们能够相当容易地将它们集成到测试中,对测试库的影响不大。我们使用基于策略的设计来简化设置和清理代码的编写。而且,该设计允许测试代码访问设置中准备好的资源。

测试套件的设置和清理更为复杂,需要从测试库中获得广泛的支持。我们不得不改变测试注册和运行的方式。但与此同时,我们简化了代码,并使其更好。测试套件的设置和清理设计采用了与局部设置和清理相同的策略,这使得整个设计保持一致。

你还学到了一些关于如何处理设置和清理代码中错误的小技巧。

下一章将继续为您提供如何编写更好测试的指导和建议。

第八章:什么是一个好的测试?

使用 TDD 开发的项目将有很多测试。但不要假设更多的或更长的测试总是更好的。你需要有好的测试。但什么是好的测试?

我们在这个章节中不会编写更多的代码。这个章节更多的是回顾我们已经遇到的一些情况,以及参考即将到来的章节中的某些测试。这是一个反思你到目前为止所学内容并展望即将到来的主题的机会。

一个好的测试应该包含以下元素:

  • 容易理解——良好的理解将导致更多测试的更好想法,并使测试更容易维护。

  • 专注于特定的场景——不要试图在一个巨大的测试中测试一切。在测试中做太多事情会破坏可理解性的第一条指导原则。

  • 可重复——使用随机行为来有时捕捉问题的测试可能在最糟糕的时候错过问题。

  • 保持与项目紧密相关——确保测试属于它们正在测试的项目。

  • 应该测试应该发生的事情而不是如何发生的事情——如果一个测试过于依赖内部工作原理,那么它将是脆弱的,当代码重构时将造成更多的工作。

本章将用示例解释这些主题。

技术要求

本章中的所有代码都来自本书的其他章节,并在此用作示例代码,以加强好的测试的想法。

使测试易于理解

使用描述性的测试名称可能是你可以做的单件最好的事情来改进你的测试。我尽可能喜欢使用简单的句子来命名我的测试。例如,我们最早创建的测试之一叫做 "Test will pass without any confirms",看起来像这样:

TEST("Test will pass without any confirms")
{
}

一个好的模式是这样的:

<object><does something><qualification>

每个部分都应该替换为你正在做的事情的具体内容。对于刚刚给出的例子,<object>Test<does something>will pass,而 <qualification>without any confirms

我并不总是遵循这个模式,尤其是在测试一个对象或类型以获得几个不同且相关结果时。例如,紧随上一个测试之后的简单测试看起来像这样:

TEST("Test bool confirms")
{
    bool result = isNegative(0);
    CONFIRM_FALSE(result);
    result = isNegative(-1);
    CONFIRM_TRUE(result);
}

对于这个简单的测试,只有两种可能性。布尔值要么是假的,要么是真的。测试专注于布尔类型,名称完全描述了测试做了什么。我的建议是,当有道理的时候,遵循命名模式。

以下是一些描述性名称如何帮助改进你的测试的方法:

  • 名称是你将在摘要描述中看到的,它将帮助任何人仅通过一眼就能理解正在测试的内容。

  • 一个描述性的名称将帮助你发现测试中的漏洞,因为当你能清楚地看到已经测试了什么时,更容易看到缺少了什么。

  • 一个遵循给定模式的描述性名称将帮助你专注于编写测试。很容易失去对测试应该做什么的跟踪,并开始包括其他事情。描述性名称将帮助你将相关的检查放在它们自己的测试中,这样它们就不会再使被测试的内容变得杂乱,并将有自己的描述性名称,这有助于它们脱颖而出。

将这三个好处结合起来,你将得到一个增强良好命名需求的反馈循环。你将自然地创建更多测试,因为每个测试都是专注的。这有助于你更好地理解正在测试的内容,从而帮助你找到缺失的测试。然后,在编写新测试时,你将保持方向,并在新想法出现时继续创建更多测试。

想象一下,如果我们采取了不同的方法,创建了一个名为“确认”的测试。它会做什么?这能激发你想到更多测试吗?你会在测试中编写什么代码?这是一个阻止更好测试循环的名称。没有人会知道测试做什么,除非阅读代码。没有人会想到新的场景,因为焦点被拖入了代码。而且测试代码本身可能散落在各处,但仍未能覆盖所有应该测试的内容。

我们不要忘记,使用 TDD 的目的是为了帮助我们驱动设计,提高软件质量,并让我们有信心重构和增强代码。描述性名称有助于所有这些。

你可能会发现,在重大重构之后,某些测试可能不再适用。这是正常的,它们可以被删除。描述性名称将帮助我们识别这些过时的测试。有时,与其删除测试,不如更新它们,专注于的测试将更容易更新。

创建良好测试的下一步是保持它们的简单性。复杂的测试通常是一个糟糕设计的症状。TDD 有助于改进设计。所以当你发现一个复杂的测试时,那就是你简化被测试项目设计的信号。

如果你能够进行简化测试的更改,那么这通常是一个双赢的局面。你得到的是更容易理解的测试,这导致软件质量更高,更容易使用。记住,测试是软件的消费者,就像任何其他组件一样。所以当你能够简化测试时,你也在简化使用相同代码的其他代码。

简化测试的一个重要部分是利用设置和清理代码。这使测试能够专注于它需要做的事情,并让我们能够阅读和理解测试的主要点,而不会被其他准备工作的代码分散注意力。

例如,在第十四章,“如何测试服务”,我向你展示了最初创建来测试服务的测试。该测试创建了一个服务本地实例并调用start。我意识到其他测试可能需要启动服务,因此它们可以共享已经启动的服务和一些设置代码。新的测试使用一个允许多个测试共享相同设置和拆卸代码的测试套件。测试看起来像这样:

TEST_SUITE("Request can be sent and response received", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Hi, " + user;
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

这个测试有一个描述性的名称,并专注于需要测试的内容,而不是创建和启动服务所需的内容。该测试使用全局gService1实例,通过service方法公开已运行的服务。

通过提供描述性名称并尽可能简化你的测试,你将发现使用 TDD(测试驱动开发)可以获得更好的结果,这将导致更好的软件设计。下一节将更详细地介绍如何专注于特定场景。

将测试集中在特定场景

上一节解释了描述性名称的一个好处是它们有助于保持测试的集中。在本节中,我们将探讨各种场景,这将为你提供关于要集中注意力的想法。

说一个测试应该集中是很不错的。但如果不知道如何确定要集中什么,那么这不会对你有帮助。建议变得空洞且令人沮丧。

这五个案例将使建议更有意义。并非所有这些案例都适用于所有情况。但拥有这些案例将对你有所帮助,有点像清单。你只需要思考每一个案例,并编写覆盖该案例的具体测试。案例如下:

  1. 正常或愉快:这是一个常见的用例。

  2. 边缘:这是正常和错误情况之间过渡的案例。

  3. 错误:这是一个需要处理的常见问题。

  4. 非正常:这是一个有效但不太常见的用例。

  5. 故意误用:这是一个故意设计来造成问题的错误案例。

让我们先从正常或愉快的情况开始。这个应该很简单,但通常会被包括其他一些案例而变得过于复杂。或者,另一种可能使其过于复杂的方式是创建一个过于模糊或不清晰表明它是正常或愉快情况的测试。

这个实际的名称可能应该是正常情况,因为这与其他情况的风格相匹配。但我经常把这个想成愉快的情况,所以我包括了两个名称。你也可以把它想成典型情况。无论你怎么想,你只需要选择一个最能描述你的代码通常使用方式的场景。我认为它是愉快的情况,因为不应该有任何错误。这应该代表预期的和典型的情况,并且应该成功。例如,在第十三章如何测试浮点数和自定义值中,有一个测试浮点值的测试,它涵盖了从 0.1 到 100 的 1000 个典型值,增量是 0.1。测试看起来像这样:

TEST("Test many float comparisons")
{
    int totalCount {1'000};
    int passCount = performComparisons<float>(totalCount);
    CONFIRM_THAT(passCount, Equals(totalCount));
}

边界情况位于一个愉快的情况和问题或错误情况之间的边界。你可能经常需要两个边界情况,其中一个代表最极端的使用方式,但仍然在正常范围内,另一个则是错误条件的开始。边界情况是良好和不良结果之间的过渡。通常会有多个边界情况。

边界情况在测试中非常重要,因为它们往往能发现很多错误,也许更重要的是,它们能让你思考你的设计。当你考虑边界情况时,你通常会接受边界情况或者改变你的设计,使得边界情况不再适用。

前一个浮点比较的边界情况是为了测试一个非常小的浮点值和一个非常大的浮点值。这两个测试是分开的,看起来像这样:

TEST("Test small float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    CONFIRM_THAT(0.000001f, NotEquals(0.000002f));
}
TEST("Test large float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    CONFIRM_THAT(9'999.0f, Equals(9'999.001f));
}

边界情况有时可能更技术性,因为通常有一个原因使得测试成为边界情况。对于浮点值,边界情况基于epsilon值。Epsilon 值在第十三章如何测试浮点值中有解释。为小和大浮点值添加测试将导致我们改变第十三章中比较浮点值的方式。这就是为什么边界情况在测试中如此有价值。

错误情况就像愉快的情况变得悲伤。想想你的代码可能需要处理的典型问题,并为该特定问题编写一个测试。就像愉快的情况有时可能过于复杂一样,这个测试也可能过于复杂。你不需要为了变化本身而包括错误情况的微小变化。只需选择你认为最能代表最常见或中间情况,并导致错误的情况,并为该单一情况创建一个测试。当然,你希望用描述性的名称命名测试,以解释情况。

例如,在第十一章 管理依赖中,有一个正常测试来确保标签可以用于过滤消息。一个错误情况几乎正好相反,确保覆盖的默认标签用于过滤消息。如果没有先阅读第十一章,这个测试可能没有意义。我将其包括在这里,作为一个错误情况的例子。注意测试末尾的CONFIRM_FALSE,这是确保日志消息不会出现在日志文件中的部分。测试看起来是这样的:

TEST("Overridden default tag not used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(), info);
    std::string message = "message ";
    message += Util::randomString();
    MereMemo::log(debug) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
}

如果你认为有多个错误情况都足够重要,应该包含在内,请将它们放入单独的测试中,并询问每个情况的不同之处。这可能会让你产生洞察力,从而改变你的设计或导致更多的测试。

我喜欢包含一些测试,这些测试超出了正常情况,但不是边界或边缘情况。这些测试仍然在有效使用范围内,应该成功,但可能会让你的代码做一点额外的工作。这种情况在帮助捕捉回归中可能很有价值。回归是一个新的错误,它代表了一个以前曾经工作过的问题。回归在做出重大设计变更后最为常见。有一些非正常但仍然预期会成功的测试将提高你在做出重大变更后对代码继续工作的信心。

最后一个情况是有意误用,出于安全原因很重要。这不仅仅是一个错误情况;这是一个精心设计的错误情况,旨在以攻击者可以用于自己目的的预测方式导致你的代码失败。对于这种情况,与其创建你知道会失败的测试,不如考虑什么会导致你的代码以惊人的方式失败。也许你的代码将负数视为错误。那么,对于有意误用,也许可以考虑使用非常大的负数。

第十四章 如何测试服务中,提到了一个关于有意误用的可能测试。我们实际上并没有创建这个测试,但我确实描述了这个测试可能的样子。在服务中,有一个表示正在进行的请求的字符串值。代码处理未识别的请求字符串,我提到一个好的测试会尝试使用一些不存在的请求调用服务,以确保服务正确处理格式不正确的请求。

关于专注于特定场景的最后一条建议,我想推荐你避免重复测试。这不是前面提到的五种情况之一,因为它适用于所有这些情况。

重复测试是指你在许多测试中反复检查相同的事情或做出相同的确认。你不需要这样做,这只会让你分心,无法专注于每个测试应该关注的内容。

如果你需要确认某个属性或结果是否正常工作,那么就为它创建一个测试。然后你可以相信它将会被测试。你不需要每次使用时都再次确认属性是否按预期工作。一旦你为某事创建了测试,那么你就不需要在其他测试中验证它是否工作。

只以这种方式使用随机行为

上一章提到了在测试中使用随机行为,了解这一点对于你的测试可预测和可重复非常重要。

可预测性和随机性几乎是你能想象到的最相反的两个属性。我们应该如何调和这两个属性呢?首先,你需要理解的是,你编写的测试应该是可预测的。如果一个测试通过了,那么除非你无法控制的外部因素失败,例如在测试过程中硬盘崩溃,否则它应该总是通过。没有办法可预测地处理这样的意外,这也不是我在谈论的。我的意思是,如果一个测试通过了,那么它应该继续通过,直到某些代码更改导致它失败。

当测试失败时,它应该继续失败,直到问题得到解决。你最不希望的是向测试中添加随机行为,以便有时做一件事,有时做另一件事。这是因为第一种行为可能会通过,而第二种行为可能会走不同的代码路径而失败。

如果你遇到失败,进行你认为可以解决问题的更改,然后通过测试,你可能会认为你的更改解决了问题。但如果是第二次测试运行恰好使用了总是通过的那个随机行为呢?这会使验证代码更改实际上是否解决问题变得困难。

更糟糕的是,当某些随机失败条件从未被执行时会发生什么?你认为所有可能的代码路径组合都在运行,但偶然之间,一个或多个条件被跳过了。这可能导致你错过应该被捕获的 bug。

我希望我已经说服你远离随机测试行为。如果你想测试不同的场景,那么编写多个测试,以便每个场景都由其自己的测试来可靠地运行。

那么,为什么我在上一章提到了使用随机性呢?我实际上确实建议你使用随机性,但不是用来决定测试做什么的方式;而是用来帮助防止不同测试运行之间的冲突。这种随机行为在创建临时表的辅助函数中提到:

std::string createTestTable ()
{
    // If this was real code, it might open a
    // connection to a database, create a temp
    // table with a random name, and return the
    // table name.
    return "test_data_01";
}

假设你有一个需要一些数据的测试。你在设置中创建数据,在测试完成后在清理中删除它。如果测试程序在测试期间崩溃,并且清理没有机会删除数据会发生什么?下次你运行测试时,很可能设置会失败,因为数据仍然存在。这就是我所说的冲突。

也许你认为,如果你增强设置以成功找到已存在的数据,那么你可以。你也可以通过其他方式得到冲突,例如在团队中编写代码时,两个团队成员几乎同时运行测试。两个设置步骤都会运行,其中一个发现数据已存在并继续。但在测试开始使用数据之前,另一个团队成员已经完成,并且清理代码删除了数据。仍在运行测试的团队成员现在会失败,因为数据不再存在。

你可以通过生成随机数据几乎完全消除这个问题。但不要随机到影响测试行为。只需随机到足以避免冲突。也许数据是通过一个名称来标识的。只要名称不是测试的一部分,名称可以稍微改变,以便每次运行测试时,数据都有一个不同的名称。createTestTable函数返回一个硬编码的名称,但注释提到随机名称可能更好。

在测试中使用完全随机行为的地方,例如在执行随机渗透测试时,你需要模糊或更改数据来模拟你否则无法为特定场景编写测试用例的情况。可能的组合数量可能太多,无法用特定的命名测试用例处理。因此,在这些情况下,编写使用随机数据来改变测试行为和结果的测试是个好主意。但这类测试不会帮助你通过 TDD 来改进你的设计。它们有补充 TDD 的位置。

当编写使用随机行为的测试时,例如处理不可计数的组合时,你需要捕获失败,因为每个失败都需要分析以找出问题。这是一个耗时过程。虽然很有价值,但当你编写测试来帮助你确定要使用的设计或评估重大设计变更的结果以查看是否有什么东西被破坏时,这不是你需要的。

对于对 TDD 最有益的测试类型,应避免任何可能改变测试结果的随机行为。这将确保你的测试可重复和可预测。

只测试你的项目

其他组件和库将被使用,可能会失败。你应该如何处理测试中的这些失败?我的建议是假设只有你的代码需要被测试。你应该假设你使用的组件和库已经过测试并且正在正常工作。

首先,记住我们使用 TDD 来改进我们自己的代码。如果你为一些你购买或在网上找到的代码编写测试,这会如何影响你自己的代码?

总是有这样的可能性,你正在使用一个开源库,并且你有一个很好的改进想法。那太好了!但是这个改进应该属于那个其他项目。它不应该出现在你自己的项目测试中。即使你在商业软件包中找到一个错误,你所能做的就是报告问题并希望它得到修复。

你最不想做的事情就是在自己的测试项目中添加一个确认,确认其他代码按预期工作。这不仅不会影响你自己的设计,实际上会使你的测试变得不那么专注。它通过添加一些对你的项目没有直接好处的干扰,削弱了你应该追求的清晰度。

本书下一章开始是第二部分,我们将构建一个日志库。日志库将是一个独立的项目,有自己的测试集。日志库还将使用我们一直在构建的测试库。想象一下,如果我们向测试库添加一个新功能,然后从日志库测试这个新功能,那会多么令人困惑。

测试应该发生的事情,而不是如何发生

我常见的一个问题是,当测试试图通过检查过程中的内部步骤来验证预期结果时。测试正在检查如何做某事。这种类型的测试很脆弱,通常需要频繁的更新和更改。

更好的方法是测试最终结果会发生什么。因为这样,内部步骤可以根据需要改变和适应。测试在整个过程中保持有效,无需进一步维护。

如果你发现自己正在频繁地更新测试,以便它们再次通过,那么你的测试可能是在测试如何做某事,而不是做了什么。

例如,在第十章“深入理解 TDD 过程”中,有一个名为何时测试过多?的部分,详细解释了应该测试什么的概念。

一般的想法是这样的。假设你有一个向集合添加过滤器的函数。如果你写一个专注于代码如何工作的测试,那么你可能会遍历集合中的项目,以确保刚刚添加的过滤器确实出现在集合中。这种方法的缺点是集合是一个内部步骤,可能会改变,这会导致测试失败。更好的方法是首先添加过滤器,然后尝试执行一个会受到过滤器影响的操作。确保过滤器按你预期的方式影响代码,并将它如何工作的内部细节留给被测试的代码。这是测试应该发生的事情,是一个更好的方法。

摘要

这更像是一个反思性的章节,你学到了一些有助于你编写更好测试的技巧。早期和后期章节的例子被用来帮助加强这些想法和指导。如果你确保考虑以下事项,你会写出更好的测试:

  • 测试应该易于理解,具有描述性的名称。

  • 宁愿使用小型且专注的测试,而不是试图做所有事情的庞大测试。

  • 确保测试是可重复的。如果一个测试失败了一次,那么它应该继续失败,直到代码被修复。

  • 一旦你测试了某个东西,你就不需要继续测试相同的东西。如果你有一些其他测试可以共享的有用代码,那么考虑将这些代码放入自己的项目,并为其设置自己的测试集。只测试你项目中的代码。

  • 测试应该发生的事情,而不是它应该如何发生。换句话说,减少对内部步骤的关注,而是验证你最感兴趣的结果。

有许多方法可以编写更好的测试。本章不应被视为包含你需要考虑的唯一事项。相反,本章确定了导致测试问题的常见问题,并为你提供了改进测试的技巧和建议。在下一章中,我们将使用 TDD 来创建一个将使用单元测试库的项目,就像你将在自己的项目中做的那样。

第二部分:使用 TDD 创建日志库

本书分为三部分。在本第二部分中,我们将使用单元测试库来设计和构建一个日志库。在这个过程中,你将看到如何在不同项目中使用 TDD,就像你将在自己的项目中做的那样。

本部分涵盖了以下章节:

  • 第九章, 使用测试

  • 第十章, 深入理解 TDD 过程

  • 第十一章, 管理依赖

第九章:使用测试

到目前为止,本书的所有内容都是关于使用 TDD 来设计和构建一个单元测试库。虽然这很有价值,但它是一个自举项目,我们使用 TDD 来帮助创建一个 TDD 的工具。

本章有所不同。我们将首次使用 TDD 来创建一个将使用单元测试库的项目。这仍然是一个旨在供其他项目使用的库。我们将创建一个日志库,并使用 TDD 和单元测试库来实现以下目标:

  • 设计一个易于使用的界面

  • 提高代码质量

  • 在保持对代码的信心的情况下,根据需要重构设计

  • 创建帮助捕捉需求和记录库用法的测试

这种方法现在应该已经很熟悉了。我们将从简单开始,先让某个功能工作,然后再增强设计并添加新功能。每个步骤都将从测试开始,以驱动设计,从而实现目标。

我们首先思考一下为什么我们要构建一个日志库。这对于确定项目的整体方向非常重要。然后我们将探讨 TDD 如何帮助我们,以及理想的日志库应该是什么样子。接着,我们将开始构建日志库。

在本章结束时,你将拥有一个简单的日志库,你可以在自己的项目中使用。但更重要的是,通过看到如何在真实项目中使用 TDD,你将获得一项技能。

技术要求

本章中所有代码都使用标准 C++,它基于任何现代 C++ 20 或更高版本的编译器和标准库。代码使用前几章中的测试库,并将启动一个新的项目。

你可以在以下 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

为什么要构建一个日志库?

在其他库中已经有很多用于日志的选择了。那么为什么还要构建另一个日志库?这不是应该是一本关于 TDD 的书吗?

这是一本实用的书,展示了如何在你的项目中使用 TDD。而实现这一目标最好的方法之一就是使用 TDD 来构建一个项目。我们需要为这本书找一个项目,我认为日志库是完美的,因为我们可以从简单开始,并在过程中增强它。日志库本身既实用又实际,这也符合本书的主题。

如果你已经有了自己的日志库或者正在使用其他地方找到的日志库,那么这本书仍然能帮助你更好地理解它是如何工作的。你还可以从构建日志库的过程中受益,这样你就可以将同样的过程应用到自己的项目中。

但我们不会满足于一个只做所有其他日志库所做的事情的日志库。遵循 TDD 方法,我们需要有一个很好的想法,知道某物将如何被使用。我们从用户的角度来处理问题。

我们将像构建微服务一样来处理这个问题。而不是构建一个作为单一应用程序的软件解决方案,这个应用程序能够完成所有需要的功能,微服务架构构建的是一些较小的应用程序,它们接受请求,执行一些期望的服务,并返回结果。有时一个服务可以调用其他服务来执行其功能。

这种架构有许多好处,本书将不会深入探讨。这种架构正在被用来在我们应用 TDD 来设计日志库时,提供一个特定的用途,这个日志库将特别适合微服务环境。

如果没有关注和了解将需要你设计的软件的具体用户,你面临的风险是设计出不符合该用户需求的东西。本书的用户将是一位编写微服务的软件开发者,需要记录服务做了什么。这就是我鼓励你在设计自己的软件时要达到的那种关注类型。因为没有这种关注,我们可能会直接跳入一个做大多数其他日志库所做的事情的日志库。我们的微服务开发者会看到一个通用目的的日志库,并发现它没有什么特别之处。

我们的目标是让同一个微服务开发者看到我们的日志库,并立即看到其好处,并想要开始使用它。下一节将展示 TDD 如何从对将使用日志库的人的了解中受益。

TDD 如何帮助构建日志库?

使用 TDD 来构建日志库,我们将获得的最大好处是客户关注。关于如何设计某物,很容易做出假设,尤其是在已经有许多类似解决方案的情况下。

这就像一条容易跟随的小径。这条小径可能会带你去你想去的地方,或者可能就在附近。如果目的地模糊或未知,那么小径就更容易跟随。但如果你确切地知道你想去哪里,那么你可以在方便的时候跟随小径,当它不再带你去你想去的地方时,你也可以离开小径。

TDD 鼓励我们思考我们想要如何使用我们正在构建的软件。这反过来又让我们能够定制解决方案,以最好地满足我们的需求。换句话说,它让我们知道何时离开小径,开始新的路径。

我们还从拥有可以验证软件行为的测试中受益,因为构建软件不像一次走过一条路。我们不是从起点开始,直接走到我们想要构建的最终软件或目的地。

相反,我们细化路径。这更像是从一个地图开始,画一条通往目的地的粗糙路径。也许这条路径与已知和现有的路径对齐,也许不对。一旦我们画出了粗糙的路径,我们就通过添加更多细节来细化它。有时细节可能会让我们改变路径。当我们完成时,我们已经走了很多次,以至于我们数不清了。

TDD 通过引导我们首先找到一个简单的解决方案并验证其是否有效来帮助我们。这就像地图上最初画出的粗糙路径。每次我们增强和细化解决方案时,我们都有测试来确保我们没有破坏任何东西。这就像检查我们是否仍然在正确的路径上。

有时,增强功能会导致需要设计变更。这就像发现一条在初始地图上没有出现的河流阻挡了我们的去路。我们需要找到一个不同的地方过河。路径改变了,但保持在新的路径上的需求仍然存在。这就是测试帮助我们的地方。我们有一个新的设计,它解决了未预见的问题,但我们仍然可以使用现有的测试来验证新的设计。我们对新的设计有信心,因为它在解决意外问题的同时,仍然按预期执行。

TDD 通过提供如何使用库的清晰和文档化的示例来帮助其他项目增加日志库的使用。这就像制作沿着路径行走的视频,以便未来的旅行者知道他们可以期待什么,以及他们是否想在开始之前跟随这条路径。在完全文档化和易于遵循的示例与声称提供相同结果但没有证据的类似库之间做出选择时,大多数人会倾向于我们的库。随着时间的推移,我们的解决方案将变得更好,因为我们吸收了用户的反馈。

让我们从思考我们想要的目的地开始。这将是我们为预期客户设计的理想日志库。那会是什么样子?这将形成我们将开始细化的第一条粗糙路径。

理想的日志库应该是什么样子?

在设计软件时,记住这一点是好的:如果你已经有了满足需求的常见解决方案,就不必设计全新的东西。仅仅为了与众不同而设计的新方案只会让人困惑。如果新的设计因为现有设计不太适用,而差异解决了真正的需求,那么这是好的。所以在我们开始梦想新的日志设计之前,让我们先看看常见的想法,看看我们是否真的需要新的东西。

要做到彻底彻底,我们也应该尝试使用 C++已经提供的功能。也许这就足够了,我们不需要一个库。假设我们有以下代码,尝试计算从 1 开始,将值翻倍三次的结果。正确答案应该是 8,但这段代码有一个错误:

#include <iostream>
int main ()
{
    int result = 1;
    for (int i = 0; i <= 3; ++i)
    {
        result *= 2;
    }
    std::cout << "result=" << result << std::endl;
    return 0;
}

它打印的结果是 16 而不是 8。你可能已经看到了问题,但让我们想象一下,代码要复杂得多,问题并不明显。按照目前的代码编写方式,我们将通过循环四次而不是三次。

我们可以添加额外的输出,如这个,以帮助找到问题:

    for (int i = 0; i <= 3; ++i)
    {
        std::cout << "entering loop" << std::endl;
        result *= 2;
    }

运行后,将产生以下输出:

entering loop
entering loop
entering loop
entering loop
result=16

结果清楚地显示循环运行了四次,而不是仅仅三次。

我们通过在输出中添加额外文本来显示代码运行时的状态,所做的一切都是日志的核心。当然,我们可以添加更多功能或做更多。但我们需要问的问题是,这足够吗,这能满足我们的需求吗?

使用 std::cout 是不够的,并且由于几个原因,它不能满足我们的需求:

这个简单的例子已经使用了控制台输出来显示结果。通常,服务应该避免向控制台发送文本,因为没有人在屏幕上监视结果。

即使控制台是程序输出的期望目的地,我们也不应该将额外的日志输出与常规输出混合。

我们可以将日志输出发送到不同的流,例如 std::cerr,但这并不是一个完整的解决方案。日志输出总是错误吗?也许它有助于我们确定程序实际上正在正确运行,而问题可能出在其他地方。

记录额外信息是有用的,但并非总是如此。直接将输出发送到 std::cout 并不能给我们提供一种在不更改源代码和重新构建的情况下关闭输出的方法。

如果日志输出包括额外的信息,例如日期和时间,那会很好。我们可以添加这些额外信息,但这样我们就必须每次调用 std::cout 来记录信息时都添加它。

我们在设计上取得了进展,因为我们刚刚消除了一条可能的路径。在寻找其他解决方案之前,总是考虑你已经拥有的东西总是好的。

如果我们将日志放入一个函数中并调用该函数而不是直接使用 std::cout,代码可能看起来像这样:

#include <fstream>
#include <iostream>
void log (std::string_view message)
{
    std::fstream logFile("application.log", std::ios::app);
    logFile << message << std::endl;
}
int main ()
{
    int result = 1;
    for (int i = 0; i <= 3; ++i)
    {
        log("entering loop");
        result *= 2;
    }
    std::cout << "result=" << result << std::endl;
    return 0;
}

这已经是一个很大的改进。尽管应用程序仍然使用 std::cout 来显示结果,但我们并没有通过更多的控制台输出为日志添加噪音。现在日志输出进入了一个文件。这也避免了将日志与常规结果混合。

我们甚至可以在 log 函数内部添加一个检查,以查看消息是否应该被记录或忽略。并且将所有内容封装在函数中也会使添加诸如日期和时间等常见信息变得容易。

理想解决方案仅仅是函数吗?

并非如此,因为我们还需要配置日志。上面显示的代码非常简单,并使用了固定的日志文件名。而且还缺少其他将改善日志体验的功能,例如:

  • 代码目前为每条消息打开和关闭日志文件。

  • 假设消息应该发送到文件。也许我们希望消息发送到其他地方或文件,以及其他地方。

  • 消息是一个单独的文本字符串。

  • 代码没有处理多个线程同时尝试记录消息的情况。

  • 日志函数使主应用程序等待直到日志消息被写入,然后应用程序才能继续。

  • 我们还没有为我们的目标客户,即微服务开发者,添加任何特定的功能,例如过滤消息的能力。

我们已经有一个很好的开始,这证实了有足够的必要来证明库的需求。

考虑其他类似解决方案也是一个好主意,而且有很多。一个著名的日志库来自 Boost C++ 库,称为 Boost.Log。这个库允许你以简单的方式开始向控制台记录日志。而且这个库是可扩展的,速度快。但它也很大。尽管它一开始很简单,但我花了好几天时间阅读文档。一件事引出另一件事,在我意识到之前,我正在学习日志库使用的其他技术。

虽然 Boost.Log 库可能一开始看起来很简单,但它很快就会要求你学习比预期多得多的内容。我希望创建一个使用起来仍然保持简单的东西。我们理想的日志库应该一开始就易于使用,并隐藏任何必要的复杂性,这样用户就不会被选项淹没。我们并不是试图构建一个可以做一切日志库。我们有一个特定的用户群体,并且将使用 TDD 来关注那个微服务开发者的需求。

下一节将开始创建日志库的过程。在我们开始编写测试之前,我们需要创建一个新的项目,下一节将解释这一点。

使用 TDD 开始一个项目

既然我们已经确定日志库是一个好主意,并且是合理的,那么是时候开始一个新的项目了。让我们从上一节中的 log 函数开始,创建一个新的项目。log 函数看起来是这样的:

void log (std::string_view message)
{
    std::fstream logFile("application.log", std::ios::app);
    logFile << message << std::endl;
}

我们将把这个 log 函数放在哪里,测试项目的结构会是什么样子?在早期章节中,我们测试了单元测试库。这是我们第一次将单元测试库作为我们正在工作的实际项目之外的东西使用。项目结构将如下所示:

MereMemo project root folder
    MereTDD folder
        Test.h
    MereMemo folder
        Log.h
        tests folder
            main.cpp
            Construction.cpp

新的结构使用一个名为 MereMemo 的包含文件夹作为项目根文件夹。就像单元测试库被称为 MereTDD 一样,我决定继续使用 mere 这个词,并将日志库命名为 MereMemo。其他选择已经被使用,而 memo 这个词代表写下某事以记住它的想法。

你可以看到,在根文件夹内部有一个名为 MereTDD 的文件夹,里面只有一个 Test.h 文件。我们不再需要包含单元测试库的测试。我们现在将使用单元测试库,而不是进一步开发它。如果我们以后需要修改单元测试库,那么我们将回到包含单元测试库测试的先前项目。

项目根文件夹为我们提供了一个放置单元测试库头文件(在它自己的文件夹中)和日志库(也在它自己的文件夹中)的地方。

MereMemo 文件夹内部有一个名为 Log.h 的文件。这就是我们将放置 log 函数的地方。还有一个名为 tests 的文件夹,它将包含日志库的单元测试。在 tests 文件夹内部,我们将找到 main.cpp 文件以及所有其他单元测试文件。目前,只有一个名为 Construction.cpp 的单元测试文件,它是空的,还没有包含任何测试。

我还应该提到,你不需要像这样将 MereTDD 文件夹放在你的项目根文件夹内。你可以将其放在任何你想要的位置。这就像在你的计算机上安装单元测试库一样。由于单元测试库实际上只是一个单独的头文件,所以没有需要安装的内容。只需要在计算机上的一个方便位置创建头文件,以便你知道路径。我们需要在开发工具的项目设置中添加路径,以便编译器知道在哪里找到 Test.h。我将在稍后解释这一步骤。

我们需要在 Log.h 中使用通常的 include 守卫,并在将 log 函数放入其中之后,Log.h 应该看起来像这样:

#ifndef MEREMEMO_LOG_H
#define MEREMEMO_LOG_H
#include <fstream>
#include <iostream>
#include <string_view>
namespace MereMemo
{
inline void log (std::string_view message)
{
    std::fstream logFile("application.log", std::ios::app);
    logFile << message << std::endl;
}
} // namespace MereMemo
#endif // MEREMEMO_LOG_H

现在的 log 函数需要内联,因为它位于自己的头文件中,并且在项目中可能会被多次包含。

我们可以主要复制 main.cpp 文件的内容,从单元测试库项目使用它来运行日志库项目的单元测试。不过,我们需要对包含 Test.h 的方式做一点小的修改。main.cpp 文件应该看起来像以下示例:

#include <MereTDD/Test.h>
#include <iostream>
int main ()
{
    return MereTDD::runTests(std::cout);
}

你可以看到,我们现在使用尖括号而不是引号来包含 Test.h。这是因为 Test.h 并不是日志库的直接部分;它现在是从另一个项目中包含的文件。从其他项目或库中包含文件的最佳方式是将它们保存在各自的文件夹中,并在你的开发工具中更改项目设置,以告诉编译器查找所需文件的路径。

对于我的开发工作,我正在使用 CodeLite 集成开发环境IDE),可以通过在项目上右键单击并选择设置菜单选项来访问项目设置。在弹出的对话框中,有一个用于编译器设置的选项。在编译器设置页面上,有一个选项可以指定包含路径。CodeLite 有一些预定义的路径,可以用来识别诸如当前项目路径之类的信息。我将包含路径设置为如下所示:

.;$(ProjectPath)

包含路径由分号分隔。您可以看到指定了两个路径。第一个是一个点,表示在当前文件夹中查找包含的文件。这就是使用引号的项目特定包含文件被找到的方式。但我还添加了一个使用美元符号和括号的特殊语法路径,这告诉 CodeLite 在项目根目录中查找额外的包含文件。实际上发生的情况是,CodeLite 解释路径,包括其特殊预定义路径,如 ProjectPath,并将实际的文件系统路径发送给编译器。编译器对ProjectPath、括号或美元符号一无所知。

如果您决定将单元测试库放在您的计算机上的其他位置,您需要添加完整路径而不是使用 ProjectPath。如果您正在使用除 CodeLite 之外的 IDE,则过程将类似。所有 IDE 都有自己指定编译器使用包含路径的方式。设置通常总是在一个可以从项目打开的设置对话框中。

在完成所有这些设置和项目配置工作后,是时候开始编写一些测试了,下一节将开始。

记录和确认第一条消息

现在我们已经准备好了一个项目,我们可以开始编写一些测试,并设计日志库。我们已创建了一个名为Construction.cpp的空单元测试文件。我喜欢从一些简单的测试开始,确保类可以被构造。我们还可以使用它来确保简单函数可以被调用。本节将专注于创建一个测试来记录我们的第一条消息并确认一切正常。

我们已经有了之前的log函数,它打开一个文件并追加一条消息。让我们添加一个调用log并写入内容的测试。以下示例显示了如何编辑Construction.cpp以添加第一个测试:

#include "../Log.h"
#include <MereTDD/Test.h>
TEST("Simple message can be logged")
{
    MereMemo::log("simple");
}

由于我们正在测试日志库,我们需要包含Log.h,它位于Construction.cpp所在的父目录中。我们使用引号来表示Log.h,因为它位于同一项目中。稍后,如果您想在自己的项目中使用日志库,只需将Log.h放在一个已知位置,并用尖括号包含它,就像我们现在用尖括号包含Test.h一样。

单个测试只是调用 log 函数。这实际上只是通过创建一个真实的项目并使用测试来代替直接在 main 中编写代码,重新组织了我们在这个章节开始时的代码。构建和运行项目向控制台显示了以下输出:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Simple message can be logged
Passed
-----------------------------------
Tests passed: 1
Tests failed: 0

单个测试运行并通过了。但是,只要它不抛出异常,它就会通过。这是因为测试中没有确认。我们真正感兴趣的输出甚至没有出现在控制台上。相反,它全部都进入了名为 application.log 的日志文件。当我从 CodeLite IDE 运行项目时,它显示了类似的输出。但似乎 CodeLite 从一个临时文件夹中运行代码。其他 IDE 也做类似的事情,有时很难跟上临时位置。因此,你可能想使用你的 IDE 来构建项目,然后打开一个单独的终端窗口来手动运行测试应用程序。这样,你就可以完全控制应用程序的运行位置,并打开一个窗口来检查创建的日志文件。

我的程序在名为 Debug 的文件夹中构建,构建后该文件夹的内容包含可执行文件和用于创建最终可执行文件的对象文件。在运行测试应用程序项目之前,没有名为 application.log 的文件。一旦项目运行,application.log 文件可以像这样打印到控制台:

$ cat application.log 
simple

$ 提示符下,使用 cat 命令来显示 application.log 文件的内容,该文件包含一行简单的消息。如果我们再次运行项目,那么我们将新内容追加到同一个日志文件中,看起来像这样:

$ cat application.log 
simple
simple

在运行应用程序两次之后,我们在日志文件中得到了两条消息。这两条消息完全相同,这将使得确定日志文件中是否添加了新内容变得困难。我们需要一种方法来创建唯一的消息,然后是验证特定消息是否出现在日志文件中的方法。这将使我们能够在测试中添加一个确认,以验证消息是否被记录,而无需每次运行测试时都手动检查日志文件。

其他测试可能需要生成唯一消息并验证日志文件内容的能力,我们可能希望将这些其他测试放在不同的 test.cpp 文件中。这意味着我们应该添加一个辅助文件来编写所需的代码,以便它可以与其他文件中的其他测试共享。

对于这种辅助文件,一个常见的名字是 Util。似乎每个项目都有 Util.hUtil.cpp,原因就在于此。这是一个放置可以在整个项目中共享的有用代码的好地方。

如果我们有这些辅助函数,测试看起来会是什么样子?将 Construction.cpp 改成以下截图的样子:

#include "../Log.h"
#include "Util.h"
#include <MereTDD/Test.h>
TEST("Simple message can be logged")
{
    std::string message = "simple ";
    message += Util::randomString();
    MereMemo::log(message);
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_TRUE(result);
}

我们需要包含Util.h,然后我们可以通过附加从调用randomString获得的随机字符串来使消息唯一。完整的消息存储在一个变量中,这样我们就可以在记录和验证时使用它。记录消息后,我们调用另一个新函数isTextInFile来验证。

这个测试的一个问题是需要指定日志文件名。目前,日志文件名硬编码在log函数中。我们不会立即修复日志文件名的问题。在使用 TDD(测试驱动开发)时,我们一步一步来。如果你有一个问题跟踪系统,将日志文件名问题添加到跟踪系统中可以确保它不会被遗忘。

现在我们已经了解了实用函数的使用方式,让我们将这两个实用文件添加到tests文件夹中的项目中,并在Util.h中添加一些函数声明,如下所示:

#ifndef MEREMEMO_TESTS_UTIL_H
#define MEREMEMO_TESTS_UTIL_H
#include <string>
#include <string_view>
struct Util
{
    static std::string randomString ();
    static bool isTextInFile (
        std::string_view text,
        std::string_view fileName);
};
#endif // MEREMEMO_TESTS_UTIL_H

第一个函数将允许我们生成一个随机字符串,我们可以用它来使消息唯一。总有可能得到重复的字符串,这可能导致我们错误地认为日志文件包含新的日志消息,而实际上我们看到的是使用了相同随机字符串的先前消息。在实践中,这不应该是一个问题,因为我们不会仅仅记录随机字符串。我们将随机字符串添加到其他文本中,这样每个测试都将具有唯一性。

当我最初开发这段代码时,我在许多测试中使用了相同的文本。添加到末尾的随机数使每个消息都是唯一的。或者至少,我没有注意到任何重复的消息。测试都进行得很顺利,直到我到达第十五章如何使用多线程进行测试,并在一个测试中添加了 150 条新消息。问题并不是由于多线程引起的。问题始终存在,直到额外的消息增加了重复消息的概率才出现。我们将通过为每个测试使用唯一的基消息文本来避免这个问题。

第二个函数将允许我们确认某些文本确实存在于文件中,我们可以使用这个来验证特定消息是否存在于日志文件中。

我喜欢在结构体中将这样的函数定义为静态方法。这有助于确保实现与头文件中的声明相匹配。实现部分放在Util.cpp中,如下所示:

#include "Util.h"
#include <fstream>
#include <random>
std::string Util::randomString ()
{
    return "1";
}
bool Util::isTextInFile (
    std::string_view text,
    std::string_view fileName)
{
    return false;
}

实现目前除了返回之外不做任何事情。但这让我们能够构建和验证测试失败。

我们为什么要确保测试失败呢?

因为这有助于在我们实际实现函数后验证通过的结果。失败确保测试被正确编写并且可以捕获失败。一旦我们实现了辅助函数,测试就会通过,我们可以确信通过是来自辅助实现,而不是一个无论如何都会通过的测试。

以下截图显示了运行项目时预期的失败情况:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Simple message can be logged
Failed confirm on line 15
    Expected: true
-----------------------------------
Tests passed: 0
Tests failed: 1

尽管手动检查application.log文件显示预期的消息确实被写入日志文件的末尾:

$ cat application.log 
simple
simple
simple 1

现在让我们修复randomString函数,以确保我们可以记录独特的消息。我们需要包含chrono来能够设置一个基于当前时间的随机数生成器。以下代码片段显示了Util.cpp中的相关代码:

#include <chrono>
#include <fstream>
#include <random>
std::string Util::randomString ()
{
    static bool firstCall = true;
    static std::mt19937 rng;
    if (firstCall)
    {
        // We only need to set the seed once.
        firstCall = false;
        unsigned int seed = static_cast<int>(
            std::chrono::system_clock::now().
            time_since_epoch().count());
        rng.seed(seed);
    }
    std::uniform_int_distribution<std::mt19937::result_type> dist(1, 10000);
    return std::to_string(dist(rng));
}

因为这使用了随机数,所以每次都会得到不同的结果。测试仍然失败,但运行测试应用程序几次之后,我的application.log文件看起来像这样:

$ cat application.log 
simple
simple
simple 1
simple 2030
simple 8731

消息现在相对独特,有轻微的重复日志消息的可能性。这对目前来说已经足够好,我们可以继续进行验证函数的工作。我们一直在测试应用程序运行之间保留日志文件,所以每次新的消息都会被附加。为了验证你的代码,一个真正的测试运行应该从一个干净的环境开始,没有任何来自之前运行的遗留文件。

我现在展示的是随机字符串和验证的代码,但没有完全解释一切。这是因为随机数和文件搜索是需要的,但它们并不完全在解释 TDD 的范围内。解释随机数的所有细节或甚至搜索文本文件以匹配字符串很容易跑题。

isTextInFile函数的实现如下:

bool Util::isTextInFile (
    std::string_view text,
    std::string_view fileName)
{
    std::ifstream logfile(fileName.data());
    std::string line;
    while (getline(logfile, line))
    {
        if (line.find(text) != std::string::npos)
        {
            return true;
        }
    }
    return false;
}

这个函数所做的只是打开日志文件,读取每一行,并尝试找到文本。如果找到文本,则返回 true,如果在任何一行中找不到文本,则函数返回 false。

构建和运行项目现在显示测试通过如下:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Simple message can be logged
Passed
-----------------------------------
Tests passed: 1
Tests failed: 0

我们现在有了一种将日志消息写入文件并确认消息出现在文件中的方法。代码可以更高效,因为目前它在查找文本时从日志文件的开始处搜索整个文件。但我们的目标不是编写最好的日志文件搜索工具。测试日志文件不太可能变得很大,所以简单的搜索方法应该可以很好地工作。

日志需要不仅仅是消息才能变得有用。下一节将向消息添加时间戳,添加日志库所需的最小功能集。

添加时间戳

单个日志文件仅通过消息可能提供一些价值,但记录每条日志消息的日期和时间会使它更有价值。一旦我们开始使用来自不同微服务的多个日志文件,按时间顺序排列日志消息的需求变得至关重要。

添加时间戳相当简单。我们只需要获取当前时间,将其格式化为一个标准的、消除年份、月份和日期之间误解的时间戳,然后将时间戳连同消息一起发送到日志中。调用者不需要做任何事情。

直接测试以确保其工作也是一项困难的工作。我们可以手动打开日志文件并查看时间戳;现在这已经足够了。我们不会为时间戳添加任何新的测试。

我们需要做的只是修改Log.h,使其看起来像这样:

#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <string>
#include <string_view>
namespace MereMemo
{
inline void log (std::string_view message)
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    std::fstream logFile("application.log", std::ios::app);
    logFile << std::put_time(std::gmtime(&tmNow),                "%Y-%m-%dT%H:%M:%S.")
               << std::setw(3) << std::setfill('0')                << std::to_string(ms.count())
               << " " << message << std::endl;
}

我们需要包含一些新的系统头文件,chronoctimeiomanipstring。一种更好的格式化日期和时间的办法是使用一个新的系统头文件format。不幸的是,尽管format是 C++20 的一部分,但它仍然没有被大多数标准库广泛实现。因此,这段代码使用了一种稍微旧一点的格式化方法,该方法使用了标准的put_time函数。

我们首先获取系统时间。然后我们需要将时间转换为一种较旧的格式,称为time_t。尽管我经常只提到时间,但我通常指的是时间和日期。

我们希望时间戳尽可能精确,仅仅记录到秒并不足够精确。因此,我们需要将时间转换为毫秒,然后除以 1,000 以得到所需的秒分数。

函数继续以前的方式打开日志文件。但现在,它使用gmtime调用put_time,我们可以确保在不同时区的机器上生成的日志将按正确顺序排列,因为所有时间都指的是同一个时区。

如果你使用的是微软的 Visual Studio 工具,你可能会在使用gmtime时遇到错误。我提到这是一个较旧的方法,一些编译器可能会抱怨gmtime可能是不安全的。推荐的替代方案是gmtime_s,但这个替代函数需要在代码中进行一些额外的检查以查看它是否可用。其他编译器也可能对gmtime提出抱怨,通常会在错误信息中告诉你如何修复问题。Visual Studio 的错误信息表示,如果我们想使用gmtime,我们需要在项目的 C++预处理器定义下定义_CRT_SECURE_NO_WARNINGS

包含百分号和大小写字母的奇怪格式化方式告诉put_time如何格式化日期和时间的各个元素。我们希望按照ISO-8601标准格式化日期和时间。最重要的是,该标准规定年份首先出现,后面跟着四位数字,然后是两位数字的月份,最后是两位数字的日期。数字之间允许使用连字符。

没有这样的标准,像 10-07-12 这样的日期对不同的人可能意味着不同的日期。那是 2012 年 10 月 7 日?还是 7 月 10 日?或者是 2010 年 7 月 12 日?或者是 2010 年 12 月 7 日?我们唯一能达成共识的是,年份可能不是 2007 年。即使使用四位数的年份,月份和日期仍然可能混淆。通过使用 ISO-8601,我们所有人都同意年份首先出现,然后是月份,然后是日期。

在标准中接下来是大写字母 T。这仅仅是将日期部分与时间部分分开。时间接下来,因为我们都同意小时先来,然后是分钟,然后是秒。我们在显示分数毫秒之前在秒后放一个点。

在进行这些更改、删除旧的日志文件、构建和运行项目几次之后,我们可以看到日志文件看起来像这样:

$ cat application.log 
2022-06-13T03:37:15.056 simple 8520
2022-06-13T03:37:17.288 simple 1187
2022-06-13T03:37:18.479 simple 2801

我们唯一没有做的是在时间戳中包含特定文本来显示时间是 UTC。我们可以添加特定时区信息,但这可能不是必需的。

我们有时间戳并且可以记录一段文本。下一节将允许我们记录多于一段文本。

使用流构建日志消息

有一个接受单个字符串以显示的log函数并不是最容易使用的函数。有时,我们可能想要记录更多信息。我们也可能想要记录不同类型的信息,而不仅仅是字符串。这就是我们可以使用 C++中广泛使用的强大流式传输能力的地方。

我们已经在log函数内部使用了一个流。我们只需要停止将单个消息文本发送到log函数内部的流,并返回流本身给调用者。然后调用者就可以自由地流式传输所需的内容。

我们可以通过首先修改测试来看到这将是什么样子;我们必须像它返回一个流一样使用log函数。修改后的测试看起来像这样:

TEST("Simple message can be logged")
{
    std::string message = "simple ";
    message += Util::randomString();
    MereMemo::log() << message << " with more text.";
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_TRUE(result);
}

现在,我们调用log函数而不带任何参数。我们不是将message变量作为参数传递,而是使用log函数的返回值作为流,并将消息与另一段文本直接发送到流中。

注意我们如何在第二段文本前添加一个空格。这是为了防止文本与前一条消息连接在一起。就像任何流一样,确保文本不会连在一起的责任在于调用者。

我们需要解决一个轻微的问题。之前,当我们处理log函数内部的消息时,我们能够向消息末尾添加换行符。我们仍然希望日志消息在日志文件中单独一行显示。但我们也不希望调用者总是需要记住添加换行符。

我们的目标之一是使这个日志库易于使用。要求每次调用log时在末尾包含换行符使得使用变得繁琐。所以,一个简单的临时解决方案是让log函数在每条日志消息的开始处添加一个换行符。

这有一个奇怪的副作用。日志文件的第一行将是空的。最后一行将没有换行符。但整体效果仍然是每个日志消息都会出现在自己的行上。这正是我们想要保持的行为。这个临时解决方案将在下一章中得到适当的修复。

Log.hlog 函数的改变也很简单,看起来像这样:

inline std::fstream log ()
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    std::fstream logFile("application.log", std::ios::app);
    logFile << std::endl
        << std::put_time(std::gmtime(&tmNow),         "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count())
        << " ";
    return logFile;
}

与返回 void 不同,这个新版本返回 std::fstream。你可以看到首先被发送到日志文件流的是 std::endl,这确保了每个日志消息在日志文件中都有其自己的行。返回 std::fstream 的整个想法是一个临时解决方案,将在下一章中得到增强。

然后,在发送时间戳之后,函数返回流而不是发送消息。这使得调用者可以将所需的任何值发送到流中。在时间戳之后添加一个空格,以确保时间戳不会遇到调用者通过流传输的额外文本。

关于返回类型的一个有趣的问题是函数返回后 fstream 会发生什么。我们在 log 函数内部构造 fstream,然后通过值返回流。在 C++11 之前,通过值返回流是不可能的,我们之所以能够这样做,是因为我们现在有了将流从 log 函数中 移动 出来的能力。代码不需要做任何特殊的事情来启用移动。它只是与现代 C++ 一起工作。我们将在下一章中再次遇到移动问题,那时我们将用新的换行符修复临时解决方案。

遵循 TDD(测试驱动开发)来设计软件鼓励工作解决方案得到增强,而不是一开始就试图设计出一个完美的设计。我可以从经验中告诉你,事先考虑每一个小设计问题是不可行的。需要沿途进行调整,这往往会使一个完美的设计变得不如最初。我喜欢 TDD,因为设计从最终用户的角度开始,增强功能围绕我们的换行问题等小问题进行。最终结果是比最初更好的。遵循 TDD 让设计保持对最重要的事物的真实,同时在需要的地方保持灵活性。

我们仍然需要考虑一旦流离开 log 函数后会发生什么。测试代码没有将流存储在局部变量中,因此它将被销毁。但它只会在创建它的表达式的末尾被销毁。让我们假设我们按照以下方式调用 log

    MereMemo::log() << message << " with more text.";

log 函数返回的 std::fstream 在行尾的分号之前保持有效。流的生存期需要保持有效,这样我们就可以继续使用它来发送消息和附加文本。

几次构建和运行项目显示每次只有一个测试通过。日志文件包含了额外的文本,如下面的截图所示:

$ cat application.log 
2022-06-13T05:01:56.308 simple 5586 with more text.
2022-06-13T05:02:02.281 simple 2381 with more text.
2022-06-13T05:02:05.621 simple 8099 with more text.

你可以看到文件开头的空行。但每个消息仍然单独一行,调用者现在可以记录其他信息。让我们创建一个新的测试来确保这一点。新的测试将类似于以下示例:

TEST("Complicated message can be logged")
{
    std::string message = "complicated ";
    message += Util::randomString();
    MereMemo::log() << message
        << " double=" << 3.14
        << " quoted=" << std::quoted("in quotes");
    bool result = Util::isTextInFile(message,     "application.log");
    CONFIRM_TRUE(result);
}

这个新的测试直接记录了双精度字面值,甚至记录了调用std::quoted函数的结果。quoted函数只是在其提供的文本周围加上引号。即使“引号中的文本”看起来已经有了引号,也要记住这些标记是为了让编译器知道文本的开始和结束。源代码中的引号实际上不是文本的一部分,就像其他字符串字面值(如“double=”)的引号不会出现在日志消息中一样。但因为我们调用了std::quoted,所以输出中会有引号。

std::quoted函数的有趣之处在于,其返回值实际上只能用于发送到流中。其实际类型由 C++标准未定义,唯一的要求是它可以发送到流。

构建和运行项目显示这两个测试都通过了。以下示例展示了在删除application.log文件并运行几次测试后,该文件看起来像什么:

$ cat application.log 
2022-06-13T05:47:36.973 simple 6706 with more text.
2022-06-13T05:47:36.975 complicated 1025 double=3.14 quoted="in quotes"
2022-06-13T05:47:39.489 simple 4411 with more text.
2022-06-13T05:47:39.495 complicated 9375 double=3.14 quoted="in quotes"

现在我们有了创建带时间戳的日志消息、将它们保存到日志文件,并将我们想要发送到每个日志消息中的任何数据的能力。对于已经熟悉向std::cout等流发送信息的 C++开发者来说,这种用法简单直观。

摘要

在本章中,你学习了如何使用单元测试库通过 TDD(测试驱动开发)开始一个新的项目。尽管我们只有两个测试,但我们已经拥有了一个易于使用且任何 C++开发者都能理解的日志库。

这两个测试将有助于确保我们在后续章节扩展日志库时不会破坏本章开始时启动的简单设计。特别是下一章将扩展日志库以更好地满足我们目标用户(微服务开发者)的需求。我们将添加标记日志消息的能力,然后使用这些标记启用强大的过滤选项。

第十章:深入探讨 TDD 过程

在本章中,我们将向日志库中添加大量代码,虽然这样做很好,但这并不是本章的主要目的。

这是一章关于测试驱动开发TDD)过程的章节。第三章不是也关于 TDD 过程吗?是的,但将前面的章节视为一个介绍。本章将详细探讨 TDD 过程,并使用更多的代码。

你将获得编写自己测试的想法,如何确定哪些是重要的,以及如何在不重写测试的情况下重构代码,你还将了解测试过多的情况以及了解许多不同类型的测试。

基本的 TDD 流程如下:

  • 首先编写使用软件的自然直观方式的测试

  • 即使我们需要提供模拟或存根实现,也要以最小的更改来构建代码

  • 为了使基本场景正常工作

  • 为了编写更多测试并增强设计

在过程中,我们将向日志库添加日志级别、标签和过滤功能。

具体来说,本章将涵盖以下主要内容:

  • 发现测试中的差距

  • 添加日志级别

  • 添加默认标签值

  • 探索过滤选项

  • 添加新的标签类型

  • 使用 TDD 重构标签设计

  • 设计测试以过滤日志消息

  • 控制要记录的内容

  • 增强相对匹配的过滤功能

  • 何时测试过多?

  • 测试应该有多侵入性?

  • 在 TDD 中,集成或系统测试去哪里?

  • 其他类型的测试怎么办?

技术要求

本章中所有代码都使用标准 C++,它构建在任何现代 C++ 20 或更高版本的编译器和标准库之上。代码使用本书第一部分测试 MVP中的测试库,并继续开发在前一章中开始的日志库。

你可以在以下 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

)

发现测试中的差距

我们真的需要更多的测试。目前,我们只有两个日志测试:一个用于简单的日志消息,另一个用于更复杂的日志消息。这两个测试如下所示:

TEST("Simple message can be logged")
{
    std::string message = "simple ";
    message += Util::randomString();
    MereMemo::log() << message << " with more text.";
    bool result = Util::isTextInFile(message,     "application.log");
    CONFIRM_TRUE(result);
}
TEST("Complicated message can be logged")
{
    std::string message = "complicated ";
    message += Util::randomString();
    MereMemo::log() << message
        << " double=" << 3.14
        << " quoted=" << std::quoted("in quotes");
    bool result = Util::isTextInFile(message,     "application.log");
    CONFIRM_TRUE(result);
}

但有没有一种好的方法来找到更多的测试?让我们看看我们到目前为止所拥有的。我喜欢从简单的测试开始。事情可以构造吗?

那就是为什么我们到目前为止的两个测试都在一个名为Contruction.cpp的文件中。当你寻找测试中的差距时,这是一个好起点。你为每个可以构造的东西都有一个简单的测试吗?通常,这些将是类。为你的项目提供的每个类的每个构造函数编写一个测试。

对于日志库,我们还没有任何类。因此,我创建了一个简单的测试,它调用log函数。然后,另一个测试以稍微复杂一些的方式调用相同的函数。

有一个论点可以提出,复杂的测试重复了一些简单测试的功能。我认为我们到目前为止做得还不错,但这是你应该注意的事情,以避免有一个测试做了另一个测试所有的事情再加上一点。只要简单测试代表了一个常见的用例,那么即使另一个测试可能做类似的事情,包含它也是有价值的。总的来说,你希望测试能够捕捉到你的代码是如何被使用的。

在寻找测试中的差距时,可以通过寻找对称性来考虑其他事情。如果你有构建测试,也许你应该考虑破坏测试。对于日志库,我们还没有这样的东西——至少,目前还没有——但这是一个需要考虑的事情。本章后面的另一个对称性例子可以找到。我们需要确认某些文本存在于文件中。为什么不包括一个类似的测试,确保某些不同的文本不存在于文件中?

主要功能是测试的好来源。想想你的代码解决了哪些问题,并为每个功能或能力编写测试。对于每个功能,创建一个简单或常见的测试,然后考虑添加一个更复杂的测试,一些探索可能出错情况的错误测试,以及一些探索更有意向的错误使用的测试,以确保你的代码按预期处理所有情况。你甚至会在下一节看到一个例子,其中添加了一个测试只是为了确保它能够编译。

本章将主要探讨缺失功能的测试。我们刚刚开始使用日志库,因此大部分新的测试都将基于新功能。这对于一个新项目来说是常见的,并且是让测试驱动开发的一个很好的方式。

下一个部分将通过首先创建测试来定义新功能,来添加一个新功能。

添加日志级别

日志库有一个共同的日志级别概念,它允许你在应用程序运行时控制记录多少信息。假设你确定了一个需要记录日志消息的错误条件。这个错误几乎总是应该被记录,但也许代码中的另一个地方你决定记录正在发生的事情可能是有用的。这个其他地方并不总是有趣的,所以避免总是看到这些日志消息会很好。

通过拥有不同的日志级别,你可以决定日志文件变得多么详细。这种方法的几个大问题包括:首先,简单地定义日志级别应该是什么,以及每个级别应该代表什么。常见的日志级别包括错误、警告、一般信息性消息和调试消息。

错误通常很容易识别,除非你还需要将它们分为普通错误和关键错误。什么使一个错误变得关键?你是否真的需要区分它们?为了支持尽可能多的不同客户,许多日志库提供了不同的日志级别,并将决定每个级别含义的任务留给程序员。

日志级别最终主要用于控制记录多少信息,这有助于在应用程序运行时没有问题或投诉的情况下减少日志文件的大小。这是一件好事,但它导致了下一个大问题。当需要进一步调查时,获取更多信息的方法只有更改日志级别,重新运行应用程序,并希望再次捕捉到问题。

对于大型应用程序,将日志级别更改为记录更多信息可能会迅速导致大量额外信息,这使得找到所需信息变得困难。额外的日志消息也可能填满存储驱动器,如果日志文件发送给供应商进行进一步处理,还可能产生额外的财务费用。调试过程通常很匆忙,因此新的日志级别只有效很短的时间。

为了绕过需要更改整个应用程序的日志级别的问题,一种常见的做法是在发现问题时临时更改代码中特定部分使用日志信息时的级别。这需要应用程序重建、部署,然后在问题解决后恢复。

所有关于日志级别的讨论如何帮助我们设计日志库?我们知道我们的目标客户是谁:一个微服务开发者,他们可能会与可以生成大量日志文件的大型应用程序一起工作。考虑什么最能帮助你的客户是一种很好的设计方法。

我们将修复已识别的两个大问题。首先,我们不会在日志库中定义任何日志级别。将不会有错误日志消息与调试日志消息之间的概念。这并不意味着将没有控制记录多少信息的方法,只是使用日志级别的整个想法在根本上是错误的。级别本身太复杂,开启和关闭它们会迅速导致信息过载和匆忙的调试会话。

在日志消息中添加额外信息,如日志级别,的想法是好的。如果我们提出一个通用的解决方案,它不仅可以用于日志级别,还可以用于其他附加信息,那么我们可以让用户添加所需和合理的任何内容。我们可以提供添加日志级别的功能,而无需实际定义这些级别将是什么以及它们代表什么。

因此,解决方案的第一部分将是一个通用 标签 系统。这应该避免由库定义的固定日志级别的混淆。我们仍然会提到日志级别的概念,但这仅仅是因为这个概念非常普遍。然而,我们的日志级别将更像日志级别标签,因为不会存在一个日志级别高于或低于另一个日志级别的概念。

第二部分将需要一些新的内容。根据日志级别标签的值来控制消息是否被记录,这只会导致之前同样的问题。开启日志级别最终会在所有地方打开日志,并仍然导致额外的日志消息泛滥。我们需要的是能够精细控制记录的内容,而不是在所有地方打开或关闭额外的日志记录。我们需要的能力是能够根据不仅仅是日志级别来过滤。

让我们一次考虑这两个想法。一个通用的标签系统会是什么样子?让我们编写一个测试来找出答案!我们应该在 tests 文件夹中创建一个名为 Tags.cpp 的新文件,如下所示:

#include "../Log.h"
#include "LogTags.h"
#include "Util.h"
#include <MereTDD/Test.h>
TEST("Message can be tagged in log")
{
    std::string message = "simple tag ";
    message += Util::randomString();
    MereMemo::log(error) << message;
    std::string taggedMessage = " log_level=\"error\" ";
    taggedMessage += message;
    bool result = Util::isTextInFile(taggedMessage,          "application.log");
    CONFIRM_TRUE(result);
}

这次测试最重要的部分是 log 函数调用。我们希望它易于使用,并能快速传达给阅读代码的任何人,其中涉及一个标签。我们不希望标签被隐藏在消息中。它应该突出显示为不同,同时又不显得使用起来尴尬。

确认部分稍微复杂一些。我们希望日志文件中的输出使用 key="value" 格式。这意味着有一些文本后面跟着一个等号,然后是引号内的更多文本。这种格式将使我们能够通过寻找类似以下内容来轻松找到标签:

key="value"

对于日志级别,我们期望输出看起来像这样:

log_level="error"

我们还希望避免诸如拼写或大小写差异之类的错误。这就是为什么语法不使用字符串,因为可能会被误输入如下:

    MereMemo::log("Eror") << message;

通过避免字符串,我们可以让编译器帮助确保标签的一致性。任何错误都应导致编译错误,而不是日志文件中的格式错误的标签。

由于解决方案使用函数参数,我们不需要提供特殊的 log 形式,如 logErrorlogInfologDebug。我们的一个目标是在库本身中避免定义特定的日志级别,而是想出一些让用户决定日志级别会是什么的东西,就像任何其他标签一样。

这也是为什么额外包含 LogTags.h 的原因,它也是一个新文件。这就是我们将定义我们将使用哪些日志级别的地方。我们希望定义尽可能简单,因为日志库不会定义这些。LogTags.h 文件应放置在 tests 文件夹中,如下所示:

#ifndef MEREMEMO_TESTS_LOGTAGS_H
#define MEREMEMO_TESTS_LOGTAGS_H
#include "../Log.h"
inline MereMemo::LogLevel error("error");
inline MereMemo::LogLevel info("info");
inline MereMemo::LogLevel debug("debug");
#endif // MEREMEMO_TESTS_LOGTAGS_H

仅因为日志库没有定义自己的日志级别,并不意味着它不能帮助完成这个常见任务。我们可以利用库定义的一个辅助类,称为LogLevel。我们包含Log.h是为了获取访问LogLevel类,以便我们可以定义实例。每个实例都应该有一个名称,例如error,这是我们将在日志记录时使用的。构造函数还需要一个用于日志输出的字符串。可能使用与实例名称匹配的字符串是个好主意。所以,例如,错误实例得到一个"``error"字符串。

正是这些实例被传递给log函数,如下所示:

    MereMemo::log(error) << message;

有一个需要注意的事项是LogLevel实例的命名空间。因为我们正在测试日志库本身,我们将在测试中调用log。每个测试体实际上是使用一个TEST宏定义的测试类的run方法的一部分。测试类本身在一个未命名的命名空间中。我想避免在使用日志级别时需要指定MereMemo命名空间,就像这样:

    MereMemo::log(MereMemo::error) << message;

直接输入error而不是MereMemo::error要简单得多。因此,目前的解决方案是在LogTags.h中全局命名空间内声明日志级别的实例。我建议当你为自己的项目定义自己的标签时,你在项目的命名空间中声明这些标签。例如,可以这样操作:

#ifndef YOUR_PROJECT_LOGTAGS_H
#define YOUR_PROJECT_LOGTAGS_H
#include <MereMemo/Log.h>
namespace yourproject
{
inline MereMemo::LogLevel error("error");
inline MereMemo::LogLevel info("info");
inline MereMemo::LogLevel debug("debug");
} // namespace yourproject
#endif // YOUR_PROJECT_LOGTAGS_H

然后,当你正在编写你自己的项目中的代码,该项目是你自己的命名空间的一部分时,你可以直接引用像error这样的标签,而不需要指定一个命名空间。你可以使用你想要的任何命名空间来代替yourproject。你可以在第十四章中看到一个很好的例子,如何 测试服务,该项目同时使用了日志库和测试库。

此外,请注意,你应该从你的项目中作为单独的项目引用Log.h文件,并使用尖括号。这就像我们在开始日志库的工作时,不得不使用尖括号引用单元测试库包含文件时所做的。

MereMemo::LogLevel的实例传递给log函数的一个额外好处是,我们不再需要指定log函数的命名空间。编译器知道在尝试解析函数名时在函数参数使用的命名空间中查找。将error传递给log函数的简单行为让编译器推断出log函数是在与error实例相同的命名空间中定义的。实际上,我在代码工作并且可以尝试不带命名空间调用log之后想到了这个好处。然后我能够向Tags.cpp添加一个看起来像这样的测试:

TEST("log needs no namespace when used with LogLevel")
{
    log(error) << "no namespace";
}

在这里,你可以看到我们可以直接调用log而不需要指定MereMemo命名空间,我们可以这样做是因为编译器知道被传递的error实例本身就是MereMemo的一个成员。

如果我们尝试不带任何参数调用log,那么我们就需要回退到使用MereMemo::log而不是仅仅使用log

此外,注意这个新测试是如何被识别的。它是一种简化代码的替代用法,编写一个测试可以帮助确保我们以后不会做任何会破坏更简单语法的操作。新的测试也没有确认。这是因为测试的存在只是为了确保调用log时没有命名空间能够编译。我们已经知道log可以发送日志消息到日志文件,因为其他测试已经确认了这一点。这个测试不需要重复确认。如果它能编译,那么它就完成了它的任务。

我们现在唯一需要的是LogLevel类的定义。记住,我们真正想要的是一个通用的标记解决方案,日志级别应该只是标记的一种类型。日志级别与其他标签之间不应该有任何特殊之处。我们不妨也定义一个Tag类,并让LogLevelTag继承。将这两个新类放在Log.h的顶部,就在MereMemo命名空间内部,如下所示:

class Tag
{
public:
    virtual ~Tag () = default;
    std::string key () const
    {
        return mKey;
    }
    std::string text () const
    {
        return mText;
    }
protected:
    Tag (std::string const & key, std::string const & value)
    : mKey(key), mText(key + "=\"" + value + "\"")
    { }
private:
    std::string mKey;
    std::string const mText;
};
class LogLevel : public Tag
{
public:
    LogLevel (std::string const & text)
    : Tag("log_level", text)
    { }
};

确保这两个类都在MereMemo命名空间内定义。让我们从Tag类开始,这个类不应该被直接使用。Tag类应该是一个基类,以便派生类可以指定要使用的键。Tag类的真正目的是确保文本输出遵循key="value"格式。

LogLevel类从Tag类继承,并且只需要日志级别的文本。键是硬编码的,总是为log_level,这保证了一致性。当我们用特定的字符串声明LogLevel的实例并调用log时,我们得到了值的一致性。

日志库支持标签,甚至支持日志级别标签,但它本身并不定义任何特定的日志级别。库也不尝试对日志级别进行排序,以便像error这样的级别比debug高或低。一切只是一个由键和值组成的标签。

现在我们有了LogLevelTag类,它们是如何被log函数使用的呢?我们首先需要一个接受Tag参数的新重载log,如下所示:

inline std::fstream log (Tag const & tag)
{
    return log(to_string(tag));
}

将这个新的log函数放在现有的log函数之后,并且仍然在Log.h中的MereMemo命名空间内。新的log函数将标签转换为字符串,并将字符串传递给现有的log函数。我们需要定义一个to_string函数,可以放在Tag类的定义之后,如下所示:

inline std::string to_string (Tag const & tag)
{
    return tag.text();
}

to_string函数只是调用Tag类中的text方法来获取字符串。我们真的需要一个函数来做这个吗?我们难道不能直接在新的重载log函数中调用text方法吗?是的,我们可以这样做,但在 C++中提供名为to_string的函数,该函数知道如何将类转换为字符串,是一种常见的做法。

所有这些新函数都需要声明在行内,因为我们打算将日志库作为一个单独的包含文件,其他项目可以简单地包含它以开始记录。我们希望避免在 Log.h 文件中声明函数,然后在 Log.cpp 文件中实现它们,因为这要求用户将 Log.cpp 添加到他们的项目中,或者要求将日志库构建为一个库,然后将其链接到项目中。通过将所有内容保持在单个头文件中,我们使其他项目使用日志库变得更加容易。它实际上不是一个库——它只是一个被包含的头文件。尽管如此,我们仍然将其称为日志库。

现有的 log 函数需要修改以接受一个字符串。实际上它曾经用于接受一个字符串作为要记录的消息,直到我们移除了这个功能,转而返回一个流,调用者可以使用这个流来指定消息以及任何其他要记录的信息。我们打算将一个字符串参数放回 log 函数中,并将其命名为 preMessagelog 函数仍然会返回一个调用者可以使用的流。preMessage 参数将用于传递格式化的标签,log 函数将在返回给调用者的流之前输出 preMessage。修改后的 log 函数看起来像这样:

inline std::fstream log (std::string_view preMessage = "")
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    std::fstream logFile("application.log", std::ios::app);
    logFile << std::endl
        << std::put_time(std::gmtime(&tmNow),            "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count())
        << " " << preMessage << " ";
    return logFile;
}

preMessage 参数有一个默认值,这样 log 函数仍然可以在没有日志级别标签的情况下被调用。log 函数所做的只是发送一个时间戳,然后是 preMessage 参数到流中,接着是一个空格,最后让调用者能够访问返回的流。

注意,我们仍然希望日志级别标签与时间戳之间也用空格隔开。如果没有指定日志级别,则输出将有两个空格,这是一个将很快修复的细节。

我们现在拥有所有需要的工具来使用新的测试中使用的日志级别进行记录:

    MereMemo::log(error) << message;

构建并运行项目显示一切通过:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Message can be tagged in log
Passed
------- Test: log needs no namespace when used with LogLevel
Passed
------- Test: Simple message can be logged
Passed
------- Test: Complicated message can be logged
Passed
-----------------------------------
Tests passed: 4
Tests failed: 0

查看新的日志文件可以看到预期的日志级别:

2022-06-25T23:52:05.842 log_level="error" simple tag 7529
2022-06-25T23:52:05.844 log_level="error" no namespace
2022-06-25T23:52:05.844  simple 248 with more text.
2022-06-25T23:52:05.844  complicated 637 double=3.14 quoted="in quotes"

前两个条目使用了新的日志级别。第二个条目是我们只想确保它能编译的。第三个和第四个日志条目缺少日志级别。这是因为它们从未指定日志级别。我们应该修复这个问题,并允许一些标签有默认值,这样我们就可以在不指定日志级别的情况下添加日志级别,以确保每个日志消息条目都有一个日志级别。第三个和第四个条目还有一个额外的空格,这也会被修复。下一节将添加指定默认标签的能力。

在继续之前,请注意一件事。复杂的日志条目实际上看起来已经使用了标签。这是因为我们使用key="value"格式格式化了消息。在文本值周围包含引号并且不在数字周围使用引号是常见的做法。当文本值内部包含空格时,引号有助于定义整个值,而数字不需要空格,因此不需要引号。

此外,请注意,我们不在等号周围添加空格。我们不会记录以下内容:

double = 3.14

我们不记录这个的原因是额外的空格不是必需的,只会使处理日志输出变得更困难。虽然带有空格可能更容易阅读,但使用脚本自动处理日志文件会更困难。

同样,我们不在标签之间使用逗号。所以,我们不会这样做:

double=3.14, quoted="in quotes"

在标签之间添加逗号可能会使其更容易阅读,但它们只是代码需要处理日志文件时必须处理的一个额外元素。逗号不是必需的,所以我们不会使用它们。

现在,我们可以继续添加默认标签。

添加默认标签值

前一节确定了有时需要在日志消息中添加标签,即使标签没有提供给log函数。我们可以利用这一点来添加默认日志级别标签或任何其他任何标签所需的默认值。

使用这个功能,我们开始需要日志库支持配置。我的意思是,我们希望在调用log之前告诉日志库如何表现,并且我们希望日志库记住这种行为。

大多数应用程序仅在配置在应用程序开始时设置一次后支持日志记录。这个配置设置通常在main函数的开始处完成。因此,让我们专注于添加一些简单的配置,这样我们就可以设置一些默认标签,并在日志记录时使用这些默认标签。如果我们遇到在调用log函数期间使用的具有相同键的默认标签和标签,那么我们将使用在log函数调用中提供的标签。换句话说,除非在log函数调用中覆盖,否则将使用默认标签。

我们将开始讨论设置默认标签值所需的内容。这是一个我们实际上不会在main内部设置默认值的测试用例,但我们将有一个测试来确保在main中设置的默认值确实出现在测试的日志输出中。我们还可以设计解决方案,以便可以在任何时间设置默认值,而不仅仅是main函数内部。这将使我们能够直接测试默认值的设置,而不是依赖于main

即使下面的代码不在测试中,我们仍然可以先修改main,以确保解决方案是我们想要的。让我们将main修改如下:

#include "../Log.h"
#include "LogTags.h"
#include <MereTDD/Test.h>
#include <iostream>
int main ()
{
    MereMemo::addDefaultTag(info);
    MereMemo::addDefaultTag(green);
    return MereTDD::runTests(std::cout);
}

我们将包含Log.h,以便我们可以获取我们将要编写的addDefaultTag函数的定义,并且我们将包含LogTags.h以获取对info日志级别和一个颜色标签的新标签的访问权限。为什么是颜色标签?因为当我们添加新测试时,我们想要寻找简单和通用的用例。我们已经有了由日志库定义的LogLevel标签,我们唯一需要做的是定义具有自己值的特定实例。但我们还没有定义我们自己的标签,这似乎是检查自定义标签是否也工作得好的好地方。使用流程良好,看起来用户想要定义多个默认标签似乎是合理的。

很容易走得太远,添加一大堆需要测试的新功能,但添加相关场景,例如添加两个默认标签infogreen,以使测试更加通用是可行的。至少,这是我可能会一步完成的事情。你可能想要将这两个测试分开。我认为我们可以添加一个单独的测试,确保即使没有提供给log函数,两个标签都存在。一个标签类型由日志库提供,另一个是自定义的,这对我来说不足以要求进行单独的测试。如果它们两个都出现在日志输出中,我会很高兴。

现在我们来向Tags.cpp添加一个测试,如下所示:

TEST("Default tags set in main appear in log")
{
    std::string message = "default tag ";
    message += Util::randomString();
    MereMemo::log() << message;
    std::string logLevelTag = " log_level=\"info\" ";
    std::string colorTag = " color=\"green\" ";
    bool result = Util::isTextInFile(message,          "application.log",
        {logLevelTag, colorTag});
    CONFIRM_TRUE(result);
}

结果表明,我很高兴我添加了两个默认标签而不是一个,因为在编写测试时,我开始思考如何验证它们两个都出现在日志文件中,那时我才意识到isTextInFile函数对于我们现在需要的来说太僵化了。当我们的兴趣仅限于检查特定字符串是否出现在文件中时,isTextInFile函数表现良好,但现在我们正在处理标签,而标签在输出中出现的顺序并未指定。重要的是,我们无法可靠地创建一个始终匹配输出中标签顺序的单个字符串,我们肯定不希望开始检查所有可能的标签顺序。

我们想要的是首先能够识别输出中的特定行。这很重要,因为我们可能有很多具有相同日志级别或相同颜色的日志文件条目,但带有随机数的消息更为具体。一旦我们在文件中找到匹配随机数的单行,我们真正想要做的是检查该行以确保所有标签都存在。行内的顺序并不重要。

因此,我将isTextInFile函数更改为接受一个第三个参数,它将是一个字符串集合。这些字符串中的每一个都将是一个要检查的单个标签值。这实际上使测试更容易理解。我们可以保持消息不变,并使用它作为第一个参数来标识我们想要在日志文件中找到的行。假设我们找到了该行,然后我们逐个将格式化的标签以key="value"格式作为字符串集合传递,以验证它们是否都存在于已找到的同一行中。

注意,标签字符串以单个空格开始和结束。这确保了标签被正确地用空格分隔,并且我们也不会在标签值末尾有任何逗号。

我们应该修复检查日志级别存在性的其他测试,如下所示:

TEST("Message can be tagged in log")
{
    std::string message = "simple tag ";
    message += Util::randomString();
    MereMemo::log(error) << message;
    std::string logLevelTag = " log_level=\"error\" ";
    bool result = Util::isTextInFile(message,          "application.log",
        {logLevelTag});
    CONFIRM_TRUE(result);
}

我们不再需要将消息追加到格式化的日志级别标签的末尾。我们只需将单个logLevelTag实例作为要检查的附加字符串集合中的单个值传递。现在,在main中设置了默认标签值,我们无法保证标签的顺序。因此,我们可能因为颜色标签恰好位于错误标签和消息之间而未能通过此测试。我们检查的只是消息是否出现在输出中,以及错误标签是否也存在于同一日志行条目中。

现在我们来增强isTextInFile函数,使其接受一个字符串向量作为第三个参数。如果调用者只想验证文件是否包含一些简单的文本,而不在相同的行上查找其他字符串,则该向量应有一个默认值为空集合。同时,我们添加一个第四个参数,它也将是一个字符串向量。第四个参数将检查确保其字符串不在行中。更新后的函数声明在Util.h中看起来如下:

#include <string>
#include <string_view>
#include <vector>
struct Util
{
    static std::string randomString ();
    static bool isTextInFile (
        std::string_view text,
        std::string_view fileName,
        std::vector<std::string> const & wantedTags = {},
        std::vector<std::string> const & unwantedTags = {});
};

我们需要包含vector并确保为额外的参数提供默认空值。Util.cpp中的实现如下:

bool Util::isTextInFile (
    std::string_view text,
    std::string_view fileName,
    std::vector<std::string> const & wantedTags,
    std::vector<std::string> const & unwantedTags)
{
    std::ifstream logfile(fileName.data());
    std::string line;
    while (getline(logfile, line))
    {
        if (line.find(text) != std::string::npos)
        {
            for (auto const & tag: wantedTags)
            {
                if (line.find(tag) == std::string::npos)
                {
                    return false;
                }
            }
            for (auto const & tag: unwantedTags)
            {
                if (line.find(tag) != std::string::npos)
                {
                    return false;
                }
            }
            return true;
        }
    }
    return false;
}

此更改在找到由text参数指定的行后添加了一个额外的for循环。对于提供的所有想要的标签,我们再次搜索该行以确保每个标签都存在。如果任何一个未找到,则函数返回false。假设它找到了所有标签,那么函数返回true,就像之前一样。

对于不想要的标签,几乎发生相同的事情,只是逻辑相反。如果我们找到一个不想要的标签,那么函数返回false

我们现在需要添加Color标签类型的定义,然后添加green颜色实例。我们可以将这些添加到LogTags.h中,如下所示:

inline MereMemo::LogLevel error("error");
inline MereMemo::LogLevel info("info");
inline MereMemo::LogLevel debug("debug");
class Color : public MereMemo::Tag
{
public:
    Color (std::string const & text)
    : Tag("color", text)
    { }
};
inline Color red("red");
inline Color green("green");
inline Color blue("blue");

构建项目显示我忘记实现了我们在main中开始使用的addDefaultTag函数。记得我曾经说过容易分心吗?我开始将函数添加到Log.h中,如下所示:

inline void addDefaultTag (Tag const & tag)
{
    static std::map<std::string, Tag const *> tags;
    tags[tag.key()] = &tag;
}

这是一个很好的例子,说明了先编写使用情况如何有助于实现。我们需要做的是存储传递给 addDefaultTag 函数的标签,以便以后可以检索并添加到日志消息中。我们首先需要一个地方来存储标签,这样函数就可以声明一个静态映射。

最初,我想让映射复制标签,但这将需要更改 Tag 类,以便它可以直接使用而不是与派生类一起使用。我喜欢派生类如何帮助保持键的一致性,并且不想改变设计的那部分。

因此,我决定使用指针来存储标签集合。使用指针的问题是,对于 addDefaultTag 的调用者来说,任何传递给函数的标签的生命周期必须保持有效,直到该标签保留在默认标签集合中。

我们仍然可以创建副本并将副本存储在唯一指针中,但这需要调用者对 addDefaultTag 进行额外的工作,或者需要一个知道如何克隆标签的方法。我不想在调用 addDefaultTagmain 代码中添加额外的复杂性,并强迫该代码进行复制。我们已经在 main 中编写了代码,我们应该努力保持该代码不变,因为它使用了 TDD 原则,并提供了我们将最满意的解决方案。

为了避免生命周期意外,我们应该在 Tag 派生类中添加一个 clone 方法。并且因为我们正在 addDefaultTag 中使用映射并已确定需要唯一指针,所以我们需要在 Log.h 的顶部包含 mapmemory,如下所示:

#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <memory>
#include <string>
#include <string_view>

现在,让我们实现正确的 addDefaultTag 函数,以便复制传入的标签而不是直接存储调用者的变量指针。这将释放调用者,使得传入的标签不再需要无限期地保持存活。将此代码添加到 Log.h 中,紧接在 LogLevel 类之后:

inline std::map<std::string, std::unique_ptr<Tag>> & getDefaultTags ()
{
    static std::map<std::string, std::unique_ptr<Tag>> tags;
    return tags;
}
inline void addDefaultTag (Tag const & tag)
{
    auto & tags = getDefaultTags();
    tags[tag.key()] = tag.clone();
}

我们使用一个辅助函数来存储默认标签的集合。该集合是静态的,因此当第一次请求标签时,它被初始化为空映射。

我们需要在 Tag 类中添加一个纯虚 clone 方法,该方法将返回一个唯一指针。方法声明可以直接放在 text 方法之后,如下所示:

    std::string text () const
    {
        return mText;
    }
    virtual std::unique_ptr<Tag> clone () const = 0;
protected:

现在,我们需要将 clone 方法的实现添加到 LogLevelColor 类中。第一个看起来像这样:

class LogLevel : public Tag
{
public:
    LogLevel (std::string const & text)
    : Tag("log_level", text)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new LogLevel(*this));
    }
};

Color 类的实现看起来几乎相同:

class Color : public MereMemo::Tag
{
public:
    Color (std::string const & text)
    : Tag("color", text)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new Color(*this));
    }
};

尽管实现看起来几乎相同,但每个都创建了一个特定类型的实例,该实例作为 Tag 的唯一指针返回。这是我开始时希望避免的复杂性,但最好是向派生类添加复杂性,而不是向 addDefaultTag 的调用者施加额外的和意外的要求。

现在,我们已经准备好构建和运行测试应用程序。其中一个测试失败了,如下所示:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Message can be tagged in log
Passed
------- Test: log needs no namespace when used with LogLevel
Passed
------- Test: Default tags set in main appear in log
Failed confirm on line 37
    Expected: true
------- Test: Simple message can be logged
Passed
------- Test: Complicated message can be logged
Passed
-----------------------------------
Tests passed: 4
Tests failed: 1

失败实际上是一件好事,它是 TDD(测试驱动开发)过程的一部分。我们像在 main 中使用它一样编写了代码,并编写了一个测试来验证默认标签是否出现在输出日志文件中。默认标签缺失,这是因为我们需要更改 log 函数,使其包含默认标签。

目前,log 函数只包括直接提供的标签——或者说,我应该说是直接提供的标签,因为我们还没有一种方法来记录多个标签。我们会达到那个地步。一次只做一件事。

我们的 log 函数目前有两个重载版本。一个接受单个 Tag 参数并将其转换为传递给另一个函数的字符串。一旦标签被转换为字符串,就很难检测到当前正在使用的标签,我们需要知道这一点,以免最终记录了具有相同键的默认标签和直接指定的标签。

例如,我们不希望日志消息同时包含 infodebug 日志级别,因为日志是用 debug 模式创建的,而 info 是默认模式。我们只想看到 debug 标签出现,因为它应该覆盖默认设置。

我们需要将标签作为 Tag 实例传递给执行输出的 log 函数,而不是字符串。然而,在调用 log 时,让我们允许调用者传递多个标签。我们应该让标签的数量无限吗?可能不是。三个看起来是个不错的数量。如果我们需要超过三个,我们会想出不同的解决方案或增加更多。

我考虑了使用模板编写接受可变数量标签的 log 函数的不同方法。虽然这可能可行,但复杂性很快变得难以处理。所以,相反,这里提供了三个重载的 log 函数,它们将参数转换为 Tag 指针的向量:

inline auto log (Tag const & tag1)
{
    return log({&tag1});
}
inline auto log (Tag const & tag1,
    Tag const & tag2)
{
    return log({&tag1, &tag2});
}
inline auto log (Tag const & tag1,
    Tag const & tag2,
    Tag const & tag3)
{
    return log({&tag1, &tag2, &tag3});
}

这些函数替换了之前将标签转换为字符串的 log 函数。新函数创建了一个 Tag 指针的向量。我们最终可能需要调用 clone 来创建副本而不是使用指向调用者参数的指针,但就目前而言,这可行,我们不必担心我们之前与默认标签相关的生命周期问题。

我们需要在 Log.h 的顶部包含 vector,在实现实际执行日志记录的 log 函数时,我最终还需要 algorithm。新的包含部分如下所示:

#include <algorithm>
#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <memory>
#include <string>
#include <string_view>
#include <vector>

现在,让我们看看对执行日志记录的 log 函数的更改。它看起来是这样的:

inline std::fstream log (std::vector<Tag const *> tags = {})
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    std::fstream logFile("application.log", std::ios::app);
    logFile << std::endl
        << std::put_time(std::gmtime(&tmNow),            "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count());
    for (auto const & defaultTag: getDefaultTags())
    {
        if (std::find_if(tags.begin(), tags.end(),
            &defaultTag
            {
                return defaultTag.first == tag->key();
            }) == tags.end())
        {
            logFile << " " << defaultTag.second->text();
        }
    }
    for (auto const & tag: tags)
    {
        logFile << " " << tag->text();
    }
    logFile << " ";
    return logFile;
}

现在函数不再接受预格式化的标签字符串,而是接受一个 Tag 指针的向量,默认值为空集合。就这个函数而言,可以有无限多个标签。三个标签的限制仅仅是因为重载的 log 函数最多接受三个标签。

tags 向量的默认值允许调用者继续使用不带参数调用 log

格式化时间戳、打开日志文件和打印时间戳的功能的第一部分保持不变,除了我们不再显示标签的预格式化字符串。

变更从第一个for循环开始,该循环检查每个默认标签。我们想要尝试在标签指针的向量中找到相同的标签键。如果我们找到相同的键,则跳过默认标签并尝试下一个。如果我们找不到相同的键,则显示默认标签。

为了进行搜索,我们使用std::find_if算法并提供一个知道如何比较键的 lambda 表达式。

在仅显示未被覆盖的默认标签之后,代码通过第二个for循环显示所有直接传递的标签。

构建并运行测试应用程序显示所有测试都通过,日志文件现在包含所有条目的默认标签,如下所示:

2022-06-26T06:24:26.607 color="green" log_level="error" simple tag 4718
2022-06-26T06:24:26.609 color="green" log_level="error" no namespace
2022-06-26T06:24:26.609 color="green" log_level="info" default tag 8444
2022-06-26T06:24:26.609 color="green" log_level="info" simple 4281 with more text.
2022-06-26T06:24:26.610 color="green" log_level="info" complicated 8368 double=3.14 quoted="in quotes"

所有日志消息都将颜色标签设置为"green",并且它们都包含log_level标签,该标签的值要么是默认值"info",要么是覆盖值"error"。对于覆盖默认值的测试,让我们确保默认值不存在。我们可以利用isTextInFile函数中的不受欢迎的标签参数,如下所示:

TEST("Message can be tagged in log")
{
    std::string message = "simple tag ";
    message += Util::randomString();
    MereMemo::log(error) << message;
    // Confirm that the error tag value exists and that the
    // default info tag value does not.
    std::string logLevelTag = " log_level=\"error\" ";
    std::string defaultLogLevelTag = " log_level=\"info\" ";
    bool result = Util::isTextInFile(message,          "application.log",
        {logLevelTag}, {defaultLogLevelTag});
    CONFIRM_TRUE(result);
}

是否应该将检查默认标签值是否不存在于日志文件中的额外检查添加到单独的测试中?单独测试的好处是它清楚地说明了正在测试的内容。缺点是测试将几乎与这个测试相同。这是一件需要思考的事情。在这种情况下,我认为在现有测试中添加额外检查和注释就足够了。

在继续之前,我们需要为我在多个标签中添加的功能添加一个测试。我真的很应该在增强代码以支持多个标签之前先为这个编写测试,但为了解释代码,一次直接解释多个标签的想法比返回并添加额外的解释要直接得多。

让我们快速在LogTags.h中添加一个名为Size的新类型Tag,并包含几个命名实例,如下所示:

class Size : public MereMemo::Tag
{
public:
    Size (std::string const & text)
    : Tag("size", text)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new Size(*this));
    }
};
inline Size small("small");
inline Size medium("medium");
inline Size large("large");

现在,这里是一个针对多个标签的测试:

TEST("Multiple tags can be used in log")
{
    std::string message = "multi tags ";
    message += Util::randomString();
    MereMemo::log(debug, red, large) << message;
    std::string logLevelTag = " log_level=\"debug\" ";
    std::string colorTag = " color=\"red\" ";
    std::string sizeTag = " size=\"large\" ";
    bool result = Util::isTextInFile(message,          "application.log",
        {logLevelTag, colorTag, sizeTag});
    CONFIRM_TRUE(result);
}

日志文件包含包含所有三个标签的条目,如下所示:

2022-06-26T07:09:31.192 log_level="debug" color="red" size="large" multi tags 9863

我们有使用最多三个直接指定的标签和多个默认标签进行日志记录的能力。我们最终需要使用标签来做的不仅仅是显示日志文件中的信息。我们希望能够根据标签值过滤日志消息,以控制哪些日志消息能够到达日志文件,哪些被忽略。我们还没有准备好进行过滤。下一节将探讨基于标签值的过滤选项。

探索过滤选项

过滤日志消息让我们能够编写在代码中的重要位置调用日志信息的代码,但忽略其中的一些日志调用。我们为什么要费尽周折添加用于日志记录的代码,然后又不进行日志记录?

对于代码中的某些事件,例如检测到的错误,始终记录该事件是有意义的。其他地方可能同样重要,即使它们不是错误。通常,这些是代码中创建或删除某些内容的地方。我说的不是创建或删除局部变量的实例。我指的是一些重大的事情,比如创建新的客户账户、完成冒险游戏中的任务,或者删除旧数据文件以释放空间。所有这些都是应该始终记录的重要事件的例子。

其他事件可能有助于开发者了解程序在崩溃前做了什么。这些日志消息就像旅途中的路标。它们不如错误或重大事件那么重要,但它们可以帮助我们了解程序在做什么。这些通常也适合记录,因为没有它们,修复错误可能会很困难。当然——错误日志可能会清楚地显示发生了不好的事情,但没有路标消息,理解导致问题的原因可能会很困难。

有时候,当我们知道导致问题的总体思路时,我们还需要更多细节。这就是我们有时想要关闭日志记录的原因,因为像这样的日志消息有时可能会非常冗长,导致日志文件的大小增加。它们也可能使看到整体情况变得困难。你有没有尝试过眼睛紧紧盯着脚下的地面走路?你可以得到每一步的详细信息,但可能会发现你迷路了。抬头看大致方向会使你难以注意到可能让你绊倒的小石头。

在编写代码时,我们希望将这些类型的日志消息全部放入代码中,因为以后添加额外的日志消息可能会很困难,尤其是在程序在远程客户位置运行时。因此,我们希望代码尝试记录一切。然后,在运行时,我们希望精确控制日志文件中显示的信息量。过滤功能使我们能够通过忽略一些日志请求来控制我们看到多少日志。

我们将根据标签及其值来过滤日志消息,但我们遇到了一个问题。

假设我们想要忽略一个日志消息,除非它具有特定的标签值。我们当前的log函数工作方式是立即打开日志文件,开始流式传输时间戳,然后添加标签,最后允许调用者发送所需的其他内容。

确定是否允许日志消息完成输出的唯一方法是在它们最终确定后查看标签。换句话说,我们需要让所有内容都像将被记录一样发送,但实际上不进行任何操作。一旦我们有了完整的消息,我们就可以查看消息,看看它是否符合发送到输出文件的准则。

这意味着我们需要做两件事不同。首先,我们需要立即停止写入日志文件,并收集所有内容,以防我们最终需要写入。其次,我们需要知道何时一个日志消息完成。我们不能简单地返回一个打开的流给调用者,让调用者随意处理流。或者说,我们不能返回一个直接修改输出日志文件的流。让调用者直接与最终的输出日志文件工作,我们无法知道调用者何时完成,以便我们可以完成并决定忽略日志还是让它继续。

我知道三种确定潜在日志消息何时完成的方法。第一种是将所有内容放入一个单独的函数调用中。该函数可以接受可变数量的参数,所以我们不会受到固定数量的限制。但是,因为整个日志消息都捆绑在一个单独的函数调用中,所以我们会知道何时拥有所有内容。它可能看起来像这样:

MereMemo::log(info, " count=", 5, " with text");

在这个例子中,我使用了一个标签实例、几个字符串字面量和一个整数。字符串字面量可以是字符串变量,或者可能是返回要记录的信息的函数调用。其中一个字符串字面量,连同数字一起,实际上形成了一个key=value标签。关键是log函数会确切地知道发送了多少信息以供记录,并且我们会知道所有值。我们可以轻松地测试日志消息,看看是否应该允许其继续,或者应该忽略它。

我们已经有了这种解决方案的初步形式,因为我们接受在log函数中最多三个标签实例。

确定日志何时完成的第二种方法是使用某种方法来终止我们现在的流。它可能看起来像这样:

MereMemo::log(info) << "count=" << 5 << " with text" << MereMemo::endlog;

注意,我们不需要在"count="字符串字面量内部添加额外的空格,因为log函数会在所有标签之后为我们添加一个。

或者,我们甚至可以允许将标签发送到流中,如下所示:

MereMemo::log() << info << " count=" << 5 << " with text" << MereMemo::endlog;

然后,我们又回到了在count字符串字面量之前需要添加前导空格的情况。这在需要调用者管理流元素之间空格的流中很常见。唯一不需要添加空格的地方是在log函数之后流出的第一个项目。

流式方法的主要思想是我们需要在末尾添加一些内容,让日志库知道所有信息都已准备好,可以与标准进行比较,以确定是否应该忽略日志。

我更喜欢流式方法。它让我感觉更开放——几乎更自然。而且由于操作符优先级和流操作符的链式操作,我们知道日志行将被评估的顺序。这可能不是非常重要,但它强化了我更喜欢流式方法的感觉。

使用这种第二种方法,调用者从log函数获取的流不能是一个直接与日志文件绑定的std::fstream实例。直接使用fstream将无法忽略日志消息,因为信息已经发送到文件中。也许我们可以返回一个与字符串绑定的流,并让终止的endlog元素发送构建的字符串到日志文件或忽略它。

如果忘记了终止的endlog元素会发生什么?终止的endlog元素需要评估日志并将其向前移动或忽略它。如果忘记了endlog,那么日志消息将不会完成。开发者可能直到需要查看日志文件时才会注意到问题,此时期望的日志消息总是被忽略。

第三种方法与第二种类似,但不需要一个可能被遗忘的终止元素。任何设计依赖于人记住做某事的时候,几乎肯定会有遗漏所需部分的情况。通过消除记住添加终止标记的需要,我们得到了一个更好的设计,它不再会因为简单的疏忽而被误用。

我们已经知道不能直接返回一个与日志文件绑定的流。第三种方法更进一步,返回一个自定义流。我们根本不使用标准流,因为我们需要在流析构函数中添加代码来完成日志记录并决定是让消息完成还是忽略它。

这种方法依赖于 C++定义的特定对象生命周期规则。我们需要确切知道析构函数何时运行,因为我们需要析构函数扮演终止的endlog元素的角色。其他使用垃圾回收来清理已删除对象的编程语言无法支持这种第三种解决方案,因为流将不会在未来的某个不确定时间被删除。C++非常明确地说明了对象实例何时被删除,我们可以依赖这个顺序。例如,我们可以这样调用log

MereMemo::log(info) << "count=" << 5 << " with text";

log返回的自定义流将在表达式结束的分号处被析构。程序员不会忘记任何事情,流将能够运行与显式的endlog元素会触发的相同代码。

也许我们可以结合所有三种方法的最佳之处。第一种函数调用方法不需要终止元素,因为它确切地知道正在传递多少个参数。第二种终止的endlog方法更加开放和自然,可以与字符串的标准流一起工作,而自定义流方法也是开放和自然的,并且避免了误用。

我最初想要创建一个能够根据整个消息过滤消息的日志库。虽然根据消息中的任何内容进行过滤似乎是最灵活和强大的解决方案,但它也是最难实现的。我们不想因为一个更容易编码而选择一个设计而不是另一个。我们应该选择一个基于最终使用的设计,这样我们会感到满意并且使用起来自然。有时,复杂的实现可能意味着最终使用也会很复杂。一个可能整体上不那么强大但更容易使用的解决方案会更好,只要我们不取消任何必需的功能。

我们应该能够去除一种过滤复杂性,而不会影响最终使用,那就是只查看通过Tag派生类形成的标签。我们应该能够取消根据手动编写的标签内容过滤日志消息的能力。

我们可以做出的另一个简化是只过滤传递给log函数的标签。这将结合第一个方法中log函数接受多个参数的方面,以及接受一系列直观信息的自定义流式传输方法。所以,看看以下流式传输示例:

MereMemo::log(info) << green << " count=" << 5 << " with text";

这里总共有三个key=value标签。第一个是info标签,然后是green标签,接着是一个手动形成的带有计数文本和数字的标签。我们不需要尝试根据所有三个标签进行过滤,我们将用于过滤的唯一信息将是info标签,因为这是唯一直接传递给log函数的标签。我们还应该根据默认标签进行过滤,因为log函数也了解默认标签。这使得理解log函数的功能变得容易。log函数启动日志记录并确定其后的任何内容是否被接受或忽略。

如果我们想在过滤时考虑green标签,那么我们只需将其添加到log函数中,就像这样:

MereMemo::log(info, green) << "count=" << 5 << " with text";

这种使用类型需要通过 TDD 进行深思熟虑。结果并不总是最强大的。相反,目标是满足用户的需求,并且易于理解和直观。

由于标签对这个设计变得越来越重要,我们应该增强它们以支持不仅仅是文本值。下一节将添加新的标签类型。

添加新的标签类型

由于我们开始用数字而不是文本来引用值,现在添加对不需要围绕值加引号的数字和布尔标签的支持将是一个好时机。

我们将在这里稍微提前一步,添加一些我们没有测试的代码。这仅仅是因为对数字和布尔标签的额外支持与我们已有的非常相似。这个更改在Log.h中的Tag类中。我们需要在现有的接受字符串的构造函数之后添加四个额外的构造函数,如下所示:

protected:
    Tag (std::string const & key, std::string const & value)
    : mKey(key), mText(key + "=\"" + value + "\"")
    { }
    Tag (std::string const & key, int value)
    : mKey(key), mText(key + "=" + std::to_string(value))
    { }
    Tag (std::string const & key, long long value)
    : mKey(key), mText(key + "=" + std::to_string(value))
    { }
    Tag (std::string const & key, double value)
    : mKey(key), mText(key + "=" + std::to_string(value))
    { }
    Tag (std::string const & key, bool value)
    : mKey(key), mText(key + "=" + (value?"true":"false"))
    { }

每个构造函数都遵循key="value"key=value语法来形成文本。为了测试新的构造函数,我们需要一些新的派生标签类。所有这些类都可以放在LogTags.h中。两个整型类看起来是这样的:

class Count : public MereMemo::Tag
{
public:
    Count (int value)
    : Tag("count", value)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new Count(*this));
    }
};
class Identity : public MereMemo::Tag
{
public:
    Identity (long long value)
    : Tag("id", value)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new Identity(*this));
    }
};

我们不会提供这些标签的命名实例。早期的ColorSize标签类型都有合理且常见的选项,但即使如此,如果需要记录奇怪的或不同寻常的颜色或尺寸,它们也可以直接使用。新的标签没有这样的常见值。

继续说,双精度标签看起来是这样的:

class Scale : public MereMemo::Tag
{
public:
    Scale (double value)
    : Tag("scale", value)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new Scale(*this));
    }
};

再次强调,它没有明显的默认值。也许我们可以为 1.0 或其他特定值提供一个命名值,但这些似乎最好由应用程序的领域定义。我们只是在测试一个日志库,并且将没有命名实例地使用这个标签。

布尔标签看起来是这样的:

class CacheHit : public MereMemo::Tag
{
public:
    CacheHit (bool value)
    : Tag("cache_hit", value)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new CacheHit(*this));
    }
};
inline CacheHit cacheHit(true);
inline CacheHit cacheMiss(false);

对于这个,我们有明显的命名值truefalse可以提供。

所有新的标签类都应该给你一个它们可以用于什么目的的思路。其中许多非常适合大型金融微服务,例如,值可能需要很长时间才能计算出来,并且需要缓存。在确定计算的流程时,记录结果是由于缓存命中还是未命中非常有价值。

我们希望能够将新的标签之一传递给log函数返回的流,如下所示:

MereMemo::log(info) << Count(1) << " message";

要做到这一点,我们需要添加一个知道如何处理Tag类的流重载。将此函数添加到Log.h中,紧接在to_string函数之后:

inline std::fstream & operator << (std::fstream && stream, Tag const & tag)
{
    stream << to_string(tag);
    return stream;
}

该函数使用对流的右值引用,因为我们正在使用log函数返回的临时流。

现在,我们可以创建一个测试,该测试将记录并确认每种新的类型。你可以为每种类型制作单独的测试,或者将它们全部放入一个测试中,如下所示:

TEST("Tags can be streamed to log")
{
    std::string messageBase = " 1 type ";
    std::string message = messageBase + Util::randomString();
    MereMemo::log(info) << Count(1) << message;
    std::string countTag = " count=1 ";
    bool result = Util::isTextInFile(message,          "application.log", {countTag});
    CONFIRM_TRUE(result);
    messageBase = " 2 type ";
    message = messageBase + Util::randomString();
    MereMemo::log(info) << Identity(123456789012345)             << message;
    std::string idTag = " id=123456789012345 ";
    result = Util::isTextInFile(message, "application.log",
        {idTag});
    CONFIRM_TRUE(result);
    messageBase = " 3 type ";
    message = messageBase + Util::randomString();
    MereMemo::log(info) << Scale(1.5) << message;
    std::string scaleTag = " scale=1.500000 ";
    result = Util::isTextInFile(message, "application.log",
        {scaleTag});
    CONFIRM_TRUE(result);
    messageBase = " 4 type ";
    message = messageBase + Util::randomString();
    MereMemo::log(info) << cacheMiss << message;
    std::string cacheTag = " cache_hit=false ";
    result = Util::isTextInFile(message, "application.log",
        {cacheTag});
    CONFIRM_TRUE(result);
}

我之所以在添加代码以启用测试之前不那么担心创建这个测试,是因为我们在开始之前已经思考了期望的使用方式。

对于双精度值的标签可能需要稍后进行更多工作以控制精度。你可以看到它使用了默认的六位小数精度。新测试的日志条目看起来是这样的:

2022-06-27T02:06:43.569 color="green" log_level="info" count=1 1 type 2807
2022-06-27T02:06:43.569 color="green" log_level="info" id=123456789012345 2 type 7727
2022-06-27T02:06:43.570 color="green" log_level="info" scale=1.500000 3 type 5495
2022-06-27T02:06:43.570 color="green" log_level="info" cache_hit=false 4 type 3938

注意到为每个log调用准备的每条消息是如何通过数字14来确保唯一的。这确保了在极少数情况下,如果生成了重复的随机数,四个日志消息中不会有相同的文本。

我们现在可以记录默认标签、直接传递给log函数的标签,以及像任何其他信息一样流出的标签。在我们实现实际过滤之前,下一节将进行一些增强,通过减少每个标签类需要编写的代码量来进一步改进标签类。

使用 TDD 重构标签设计

在测试中,我们有一个基类 Tag 和几个派生标签类。尽管日志库将只定义日志级别标签,但它仍然应该使开发者能够轻松创建新的派生标签类。目前,创建一个新的派生标签类主要是需要重复多次的样板代码。我们应该能够通过使用模板来提高这种体验。

下面是一个现有的派生标签类的样子:

class LogLevel : public Tag
{
public:
    LogLevel (std::string const & text)
    : Tag("log_level", text)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new LogLevel(*this));
    }
};

LogLevel 派生的标签类是日志库将提供的唯一此类类。它定义了日志级别标签,但实际上并没有定义任何特定的日志级别值。更好的说法是,这个类定义了日志级别应该是什么。

我们可以将 LogLevel 类与测试中其他派生标签类之一进行比较。让我们选择 CacheHit 类,它看起来是这样的:

class CacheHit : public MereMemo::Tag
{
public:
    CacheHit (bool value)
    : Tag("cache_hit", value)
    { }
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new CacheHit(*this));
    }
};

我们可以对这些类进行哪些改进?它们几乎相同,只有一些可以移动到模板类中的不同之处。这两个类有什么不同?

  • 显然是名称。LogLevelCacheHit

  • 父类命名空间。LogLevel 已经在 MereMemo 命名空间中。

  • 关键字符串。LogLevel 使用 "log_level",而 CacheHit 使用 "cache_hit"

  • 值的类型。LogLevel 使用 std::string 值,而 CacheHit 使用 bool 值。

这些就是所有的区别。不应该需要开发者每次需要新的标签类时都重新创建所有这些。而且,我们需要向标签类添加更多代码以支持过滤,所以现在是简化设计的好时机。

我们应该能够在不影响任何现有测试的情况下进行即将到来的过滤更改,但这将需要现在进行设计更改。我们正在重构设计,测试将有助于确保新设计继续像当前设计一样表现。从知道一切仍然正常工作所获得的信心是使用 TDD 的一大好处。

Tag 类代表了一个所有标签都支持的接口。我们将保持其原样并保持简单。我们不会更改 Tag 类,而是引入一个新的模板类,它可以包含 clone 方法实现以及任何即将到来的过滤更改。

Log.h 中的 LogLevel 类更改为使用新的 TagType 模板类,该类可以使用不同类型的值,如下所示:

template <typename T, typename ValueT>
class TagType : public Tag
{
public:
    std::unique_ptr<Tag> clone () const override
    {
        return std::unique_ptr<Tag>(
            new T(*static_cast<T const *>(this)));
    }
    ValueT value () const
    {
        return mValue;
    }
protected:
    TagType (ValueT const & value)
    : Tag(T::key, value), mValue(value)
    { }
    ValueT mValue;
};
class LogLevel : public TagType<LogLevel, std::string>
{
public:
    static constexpr char key[] = "log_level";
    LogLevel (std::string const & value)
    : TagType(value)
    { }
};

我们仍然有一个名为 LogLevel 的类,可以像以前一样使用。现在它指定了值的类型,即 std::string,在 TagType 模板的参数中,而 key 字符串现在是一个由每个派生标签类定义的常量字符数组。LogLevel 类更简单,因为它不再需要处理克隆。

新的TagType模板类做了大部分的艰苦工作。目前,这项工作只是克隆,但我们需要添加更多功能来实现过滤。我们应该能够将这些即将到来的功能放入TagType类中,并保持派生标签类不变。

这种设计的工作方式基于某种称为LogLevel继承自TagType,而TagTypeLogLevel作为其模板参数之一。这使得TagType能够在clone方法内部回指LogLevel以构造一个新的LogLevel实例。如果没有 CRTP,那么TagType将无法创建新的LogLevel实例,因为它不知道要创建什么类型。

并且TagType需要再次回指LogLevel以获取键名。TagType通过再次引用 CRTP 在T参数中给出的类型来实现这一点。

clone方法稍微复杂一些,因为当我们处于clone方法内部时,我们处于TagType类中,这意味着this指针需要被转换为派生类型。

我们现在可以简化LogTags.h中的其他派生标签类型。ColorSize类型都使用std::string作为值类型,就像LogLevel一样,它们看起来是这样的:

class Color : public MereMemo::TagType<Color, std::string>
{
public:
    static constexpr char key[] = "color";
    Color (std::string const & value)
    : TagType(value)
    { }
};
class Size : public MereMemo::TagType<Size, std::string>
{
public:
    static constexpr char key[] = "size";
    Size (std::string const & value)
    : TagType(value)
    { }
};

CountIdentity类型都使用不同长度的整数值类型,看起来是这样的:

class Count : public MereMemo::TagType<Count, int>
{
public:
    static constexpr char key[] = "count";
    Count (int value)
    : TagType(value)
    { }
};
class Identity : public MereMemo::TagType<Identity, long long>
{
public:
    static constexpr char key[] = "id";
    Identity (long long value)
    : TagType(value)
    { }
};

Scale类型使用double值类型,看起来是这样的:

class Scale : public MereMemo::TagType<Scale, double>
{
public:
    static constexpr char key[] = "scale";
    Scale (double value)
    : TagType(value)
    { }
};

CacheHit类型使用bool值类型,看起来是这样的:

class CacheHit : public MereMemo::TagType<CacheHit, bool>
{
public:
    static constexpr char key[] = "cache_hit";
    CacheHit (bool value)
    : TagType(value)
    { }
};

每个派生标签类型都比以前简单得多,可以专注于每个类型的独特之处:类名、键名和值的类型。

下一节将创建基于逻辑标准的过滤测试,这将允许我们指定应该记录什么,我们还将使用简化的标签类以及clone方法。

设计过滤日志消息的测试

过滤日志消息将是日志库最重要的功能之一。这就是为什么本章投入了如此多的努力来探索想法并增强设计。大多数日志库都提供了一些过滤支持,但通常仅限于日志级别。日志级别通常也是有序的,当你设置一个日志级别时,你会得到所有等于或高于过滤级别的日志。

这对我来说总是显得很随意。日志级别是上升还是下降?将过滤级别设置为info是否意味着你也会得到debug,或者只是infoerror日志?

这忽略了更大的问题——信息过载。一旦你弄清楚如何获取调试级别的日志,它们都会被记录下来,日志很快就会填满。我甚至见过日志填得如此快,以至于我感兴趣的日志消息已经被压缩并即将被删除以节省空间,在我能够退出应用程序查看发生了什么之前。

我们的目标客户是日志库的微服务开发者。这意味着正在开发的应用程序可能很大且分布广泛。在单个服务中甚至是在所有地方开启调试日志会导致很多问题。

我们正在构建的日志库将解决这些问题,但我们需要从简单开始。在Tags.cpp中的这个测试就是一个好的开始:

TEST("Tags can be used to filter messages")
{
    int id = MereMemo::createFilterClause();
    MereMemo::addFilterLiteral(id, error);
    std::string message = "filter ";
    message += Util::randomString();
    MereMemo::log(info) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
    MereMemo::clearFilterClause(id);
    MereMemo::log(info) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_TRUE(result);
}

这个测试的想法是首先设置一个过滤器,该过滤器将导致日志消息被忽略。我们确认该消息没有出现在日志文件中。然后,测试清除过滤器,并再次尝试记录相同的消息。这次,它应该出现在日志文件中。

通常,过滤器匹配应允许日志继续,而没有任何匹配应导致消息被忽略。但是,当没有任何过滤器设置时,我们应该让所有内容通过。不设置任何过滤器让所有内容通过,让用户可以选择是否进行过滤。如果正在使用过滤,那么它将控制日志输出,但当没有过滤器时,不让任何内容通过就显得很奇怪。当测试集设置了一个与日志消息不匹配的过滤器时,由于已经启用了过滤,该消息就不会出现在日志文件中。当清除过滤器时,我们假设没有设置其他过滤器,所有日志消息将再次被允许继续。

我们将根据析取范式DNF)中的公式来过滤日志。DNF 指定了一个或多个通过 OR 运算组合在一起的子句。每个子句包含通过 AND 运算组合在一起的文字。这里的“文字”不是 C++意义上的文字。在这里,“文字”是一个数学术语。子句中的每个文字可以是直接 AND 运算,也可以先进行 NOT 运算。所有这些都是布尔逻辑,并且能够表示从简单到复杂的任何逻辑条件。解释 DNF 的所有细节不是本书的目的,因此我不会解释 DNF 背后的所有数学。只需知道 DNF 足够强大,可以表示我们所能想到的任何过滤器。

这是一个需要强大解决方案的案例。即便如此,我们仍将专注于最终用途,并尽可能使解决方案易于使用。

测试调用一个createFilterClause函数,该函数返回创建的子句的标识符。然后,测试调用addFilterLiteral向刚刚创建的子句添加一个error标签。测试试图完成的是,只有当error标签存在时,才完成日志。如果这个标签不存在,那么日志应该被忽略。并且记住,为了使标签被考虑,它必须存在于默认标签中或直接提供给log函数。

然后,测试调用另一个函数clearFilterClause,该函数旨在清除刚刚创建的过滤器子句,并再次允许所有内容被记录。

通常,微服务开发者不会运行一个完全空的过滤应用,因为这会让所有日志消息都通过。在任何时候都可能存在一些过滤。只要至少有一个过滤子句是激活的,过滤就会只允许与其中一个子句匹配的消息继续。通过允许多个子句,我们实际上是在让额外的日志消息通过,因为每个额外的子句都有机会匹配更多的日志消息。我们将能够通过一个强大的布尔逻辑系统来调整记录的内容。

一个大型项目可以添加标识不同组件的标签。调试日志可以只为某些组件或其他匹配标准打开。额外的逻辑在调试会话期间增加了更多灵活性,可以在不影响其他区域并保持正常日志级别的同时,增加对有趣区域的日志记录。

如果一个标签存在于默认标签中,但在调用log时被直接覆盖,会发生什么?我们应该忽略默认标签,而选择显式的标签吗?我认为是这样,这将是一个很好的测试案例。边缘情况如此类实有助于定义项目并提高使用 TDD 获得的好处。现在让我们添加这个测试,以免忘记。它看起来是这样的:

TEST("Overridden default tag not used to filter messages")
{
    int id = MereMemo::createFilterClause();
    MereMemo::addFilterLiteral(id, info);
    std::string message = "override default ";
    message += Util::randomString();
    MereMemo::log(debug) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
    MereMemo::clearFilterClause(id);
}

这个测试依赖于info标签已经在默认标签中设置。我们可能需要添加测试默认标签的能力,以便如果info在默认标签中找不到,测试会失败,并且我们需要确保在测试结束时清除过滤子句,以免影响其他测试。之前的测试也清除了子句,但在测试的特定点上。即便如此,之前的测试应该有一个更强的保证,即测试不会以过滤子句仍然设置的状态结束。我们应该利用测试拆解来确保在任何创建过滤子句的测试结束时始终清除过滤子句。

在继续添加拆解步骤之前,我刚开始解释的测试想法是这样的。在设置一个只允许带有info标签的日志的子句后,日志消息应该被允许继续,因为它将通过默认的标签集获得info标签。但相反,日志覆盖了info标签,使用了debug标签。最终结果是,日志消息不应该出现在输出日志文件中。

为了确保即使在测试失败并抛出异常之前测试未到达末尾,我们也能始终清除过滤子句,我们需要在Tags.cpp中定义一个设置和拆解类,如下所示:

class TempFilterClause
{
public:
    void setup ()
    {
        mId = MereMemo::createFilterClause();
    }
    void teardown ()
    {
        MereMemo::clearFilterClause(mId);
    }
    int id () const
    {
        return mId;
    }
private:
    int mId;
};

如果你想了解更多关于设置和拆解类的信息,请参阅第七章测试设置拆解

在适当的时候,测试自己清除过滤器是可以的。添加一个SetupAndTeardown实例将确保即使它已经被调用,也会调用clearFilterClause函数。本节的第一项测试看起来像这样:

TEST("Tags can be used to filter messages")
{
    int id = MereMemo::createFilterClause();
    MereMemo::addFilterLiteral(id, error);
    std::string message = "filter ";
    message += Util::randomString();
    MereMemo::log(info) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
    MereMemo::clearFilterClause(id);
    MereMemo::log(info) << message;
    result = Util::isTextInFile(message, "application.log");
    CONFIRM_TRUE(result);
}

测试现在从设置和清理实例中获取条款 ID。ID 用于添加过滤字面量和在正确的时间清除过滤条款。过滤条款将在测试结束时再次清除,但没有效果。

本节中的第二个测试不再需要显式清除过滤器本身,只需要添加SetupAndTeardown实例,如下所示:

TEST("Overridden default tag not used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(), info);
    std::string message = "override default ";
    message += Util::randomString();
    MereMemo::log(debug) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
}

这个测试在结束时调用clearFilterClause以将过滤器放回未过滤的状态。测试不再需要直接调用clearFilterClause,因为依赖于SetupAndTeardown析构函数更可靠。

我们有两个过滤测试调用尚未存在的函数。让我们在Log.h文件中addDefaultTag函数之后添加以下函数占位符:

inline int createFilterClause ()
{
    return 1;
}
inline void addFilterLiteral (int filterId,
    Tag const & tag,
    bool normal = true)
{
}
inline void clearFilterClause (int filterId)
{
}

目前createFilterClause函数只是返回1。它最终需要为每个创建的条款返回不同的标识符。

addFilterLiteral函数将给定的标签添加到指定的条款中。normal参数将允许我们通过传递false来添加 NOT 或反转的字面量。小心处理此类标志的含义。当我第一次写这个时,标志被命名为invert,默认值为false。我没有注意到这个问题,直到为反转过滤器编写测试,并且传递true以获取反转字面量看起来很奇怪。测试突出了反向使用,而初始函数声明让它悄悄溜走,没有被发现。

clearFilterClause函数目前没有任何作用。我们稍后需要有一些可以操作的条款集合。

占位符过滤函数让我们构建和运行测试应用程序。我们得到两个测试失败,如下所示:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Message can be tagged in log
Passed
------- Test: log needs no namespace when used with LogLevel
Passed
------- Test: Default tags set in main appear in log
Passed
------- Test: Multiple tags can be used in log
Passed
------- Test: Tags can be streamed to log
Passed
------- Test: Tags can be used to filter messages
Failed confirm on line 123
    Expected: false
------- Test: Overridden default tag not used to filter messages
Failed confirm on line 143
    Expected: false
------- Test: Simple message can be logged
Passed
------- Test: Complicated message can be logged
Passed
-----------------------------------
Tests passed: 7
Tests failed: 2

预期结果是使用 TDD(测试驱动开发)。我们只做了必要的最小工作来构建代码,以便我们可以看到失败。我们可以在占位符函数中添加更多的实现。

我提到我们需要一个条款的集合。在Log.h文件中,在占位符过滤函数之前添加以下函数:

struct FilterClause
{
    std::vector<std::unique_ptr<Tag>> normalLiterals;
    std::vector<std::unique_ptr<Tag>> invertedLiterals;
};
inline std::map<int, FilterClause> & getFilterClauses ()
{
    static std::map<int, FilterClause> clauses;
    return clauses;
}

模式与我们为默认标签所做的是相似的。有一个名为getFilterClauses的函数,它返回一个静态FilterClause对象映射的引用,而FilterClause结构体被定义为包含正常和反转字面量的几个向量。字面量是指从克隆中获得的标签的指针。

createFilterClause函数可以实施以使用条款集合,如下所示:

inline int createFilterClause ()
{
    static int currentId = 0;
    ++currentId;
    auto & clauses = getFilterClauses();
    clauses[currentId] = FilterClause();
    return currentId;
}

这个函数通过一个静态变量跟踪当前 id,每次函数被调用时都会递增。需要完成的唯一其他任务是创建一个空的过滤条款记录。id 被返回给调用者,以便稍后可以修改或清除过滤条款。

addfilterLiteral函数可以像这样实现:

inline void addFilterLiteral (int filterId,
    Tag const & tag,
    bool normal = true)
{
    auto & clauses = getFilterClauses();
    if (clauses.contains(filterId))
    {
        if (normal)
        {
            clauses[filterId].normalLiterals.push_back(
                tag.clone());
        }
        else
        {
            clauses[filterId].invertedLiterals.push_back(
                tag.clone());
        }
    }
}

这个函数确保在将克隆指针推入正常或反转向量之前,clauses集合包含给定过滤 id 的条目。

clearFilterClause函数是最简单的,因为它只需要获取集合并删除具有给定 id 的任何过滤条款,如下所示:

inline void clearFilterClause (int filterId)
{
    auto & clauses = getFilterClauses();
    clauses.erase(filterId);
}

我们仍然需要在记录日志时检查过滤条款,这将在下一节中解释。在遵循 TDD 时,当代码构建并且运行时测试失败时,让测试通过是很好的。让我们在下一节中让测试通过!

控制要记录的内容

在本章早期,当我们探索过滤选项时,我提到过我们需要一个自定义流类,而不是从log函数返回std::fstream。我们需要这样做,以便我们不会立即将信息发送到日志文件。我们需要避免直接将日志消息发送到日志文件,因为可能存在过滤规则,这些规则可能导致日志消息被忽略。

我们还决定,我们将完全基于默认标签和直接发送到log函数的任何标签来决定是否记录。我们可以让log函数做出决定,如果日志消息应该继续,则返回std::fstream,如果日志消息应该被忽略,则返回一个假流,但可能更好的做法是始终返回相同的类型。这似乎是最简单、最直接的方法。在流类型之间切换似乎是一个更复杂的解决方案,仍然需要自定义流类型。

使用自定义流类型也将使我们能够解决一个令人烦恼的问题,即我们不得不在每个日志消息之前而不是之后放置换行符。这导致了日志文件的第一行是空的,最后一行突然结束。我们选择了在每次日志消息之前放置换行符的临时解决方案,因为我们当时没有东西可以让我们知道所有信息都已经流过。

好吧,自定义流类将使我们能够解决这个令人烦恼的换行符问题,并给我们一个避免直接将日志消息写入日志文件的方法。让我们从新的流类开始。在Log.h中创建这个类,在log函数之前,如下所示:

class LogStream : public std::fstream
{
public:
    LogStream (std::string const & filename,
        std::ios_base::openmode mode = ios_base::app)
    : std::fstream(filename, mode)
    { }
    LogStream (LogStream const & other) = delete;
    LogStream (LogStream && other)
    : std::fstream(std::move(other))
    { }
    ~LogStream ()
    {
        *this << std::endl;
    }

    LogStream & operator = (LogStream const & rhs) = delete;
    LogStream & operator = (LogStream && rhs) = delete;
};

我们将一次解决一个问题。因此,我们将继续重构这个类,直到它完成我们需要的所有事情。现在,它只是从std::fstream继承,所以它不会解决直接写入日志文件的问题。构造函数仍然打开日志文件,所有的流能力都是从fstream继承的。

这个类所解决的问题就是换行问题。它是通过在类的析构函数中向流发送std::endl来解决的。基于提供的名称打开文件和添加换行的析构函数是这个类解决问题的关键部分。类的其余部分是为了使代码能够编译和正常工作。

由于我们添加了一个析构函数,这引发了一系列其他要求。我们现在需要提供一个复制构造函数。实际上,我们需要的是移动复制构造函数,因为流在复制时往往会表现得奇怪。复制流不是一个简单的任务,但将流移动到另一个流中要简单得多,并且已经完成了我们需要的所有事情。我们不需要复制流,但我们确实需要从log函数返回流,这意味着流要么需要被复制,要么需要被移动。因此,我们显式删除了复制构造函数并实现了移动复制构造函数。

我们还删除了赋值运算符和移动赋值运算符,因为我们不需要对流进行赋值。

我们可以通过修改log函数来使用新的LogStream类,使其看起来像这样:

inline LogStream log (std::vector<Tag const *> tags = {})
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    LogStream ls("application.log");
    ls << std::put_time(std::gmtime(&tmNow),        "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count());
    for (auto const & defaultTag: getDefaultTags())
    {
        if (std::find_if(tags.begin(), tags.end(),
            &defaultTag
            {
                return defaultTag.first == tag->key();
            }) == tags.end())
        {
            ls << " " << defaultTag.second->text();
        }
    }
    for (auto const & tag: tags)
    {
        ls << " " << tag->text();
    }
    ls << " ";
    return ls;
}

现在的log函数返回一个LogStream实例而不是std::fstream。在函数内部,它创建一个LogStream实例,就像它是一个fstream实例一样。唯一改变的是类型。现在文件打开模式默认为append,因此我们不需要指定如何打开文件。流的名字改为ls,因为这已经不再是一个日志文件了。

然后,在发送初始时间戳时,我们不再需要发送初始的std::endl实例,可以直接开始发送时间戳。

在这些更改之后,测试应用程序运行时唯一的不同之处在于日志文件将不再有空的第一个行,并且所有行都将以换行符结束。

那个小问题已经解决了。那么,直接写入日志文件的大问题怎么办?我们仍然希望写入标准流,因为实现我们自己的流类会增加我们目前并不真正需要的很多复杂性。因此,我们不会从std::fstream继承LogStream类,而是从std::stringstream继承。

我们需要包含sstream以获取stringstream的定义,我们也可以现在就包含ostream。我们需要ostream来更改Log.h中的流辅助函数,该函数目前使用std::fstream,我们将将其改为如下所示:

inline std::ostream & operator << (std::ostream && stream, Tag const & tag)
{
    stream << to_string(tag);
    return stream;
}

我们可能从一开始就应该实现这个辅助函数来使用ostream。这样,我们可以将标签流式传输到任何输出流。由于fstreamstringstream都基于ostream,我们可以使用这个辅助函数将流式传输到两者。

这里是更新后的Log.h的包含内容:

#include <algorithm>
#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <map>
#include <memory>
#include <ostream>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

从技术上讲,我们不需要包含 ostream,因为我们已经通过包含 fstreamstringstream 获得了它。但我喜欢包含我们直接使用的东西的头文件。在查看包含的头文件时,我发现我们包含了 iostream。我认为我最初包含 iostream 是为了获取 std::endl 的定义,但看起来 endl 实际上是在 ostream 中声明的。所以,根据我包含使用头文件的规则,我们应该从一开始就包含 ostream 而不是 iostream

回到 LogStream,我们需要将这个类修改为从 stringstream 继承,如下所示:

class LogStream : public std::stringstream
{
public:
    LogStream (std::string const & filename,
        std::ios_base::openmode mode = ios_base::app)
    : mProceed(true), mFile(filename, mode)
    { }
    LogStream (LogStream const & other) = delete;
    LogStream (LogStream && other)
    : std::stringstream(std::move(other)),
    mProceed(other.mProceed), mFile(std::move(other.mFile))
    { }
    ~LogStream ()
    {
        if (not mProceed)
        {
            return;
        }
        mFile << this->str();
        mFile << std::endl;
    }
    LogStream & operator = (LogStream const & rhs) = delete;
    LogStream & operator = (LogStream && rhs) = delete;
    void ignore ()
    {
        mProceed = false;
    }
private:
    bool mProceed;
    std::fstream mFile;
};

有一个新的数据成员叫做 mProceed,我们在构造函数中将它设置为 true。由于我们不再从 std::fstream 继承,我们现在需要一个文件流的数据成员。我们还需要初始化 mFile 成员。移动拷贝构造函数需要初始化数据成员,析构函数检查是否应该继续日志记录。如果应该继续日志记录,那么 stringstream 的字符串内容将被发送到文件流。

我们还没有实现过滤,但我们已经接近了。这个更改让我们达到了可以控制日志记录的点。除非在析构函数运行之前调用 ignore,否则日志记录将继续进行。这个简单的更改将使我们能够构建和测试,以确保我们没有破坏任何东西。

运行测试应用程序显示与过滤相关的相同两个测试失败。主要的事情是其他测试继续通过,这表明当我们直接将流直接写入文件流时,使用 stringstream 的更改仍然像以前一样工作。

在进行诸如切换流等关键更改时,确保没有东西被破坏是很重要的。这就是为什么我选择了一个硬编码的选择,总是进行日志记录。我们可以使用我们已有的 TDD 测试来验证在添加过滤之前,流更改是否正常工作。

让我们分两部分来看一下对 log 函数的下一个修改。在确定哪些默认标签被覆盖之后,我们需要收集完整的活动标签集合。我们不需要直接将标签发送到流中,而是可以先将其放入一个活动集合中,如下所示:

inline LogStream log (std::vector<Tag const *> tags = {})
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    LogStream ls("application.log");
    ls << std::put_time(std::gmtime(&tmNow),        "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count());
    std::map<std::string, Tag const *> activeTags;
    for (auto const & defaultTag: getDefaultTags())
    {
        activeTags[defaultTag.first] = defaultTag.second.get();
    }
    for (auto const & tag: tags)
    {
        activeTags[tag->key()] = tag;
    }
    for (auto const & activeEntry: activeTags)
    {
        ls << " " << activeEntry.second->text();
    }
    ls << " ";
    // Filtering will go here.
    return ls;
}

这样不仅得到了活动集合,而且看起来也更简单。我们让映射首先处理覆盖,将所有默认标签放入映射中,然后将所有提供的标签放入映射中。构建和运行测试应用程序显示,这个修改并没有破坏任何新的东西。因此,我们准备进行下一部分,即比较过滤子句与活动标签。

过滤需要更改 log 函数的最后部分,其中注释表明过滤将在这里进行,如下所示:

    bool proceed = true;
    for (auto const & clause: getFilterClauses())
    {
        proceed = false;
        bool allLiteralsMatch = true;
        for (auto const & normal: clause.second.normalLiterals)
        {
            // We need to make sure that the tag is
            // present and with the correct value.
            if (not activeTags.contains(normal->key()))
            {
                allLiteralsMatch = false;
                break;
            }
            if (activeTags[normal->key()]->text() !=
                normal->text())
            {
                allLiteralsMatch = false;
                break;
            }
        }
        if (not allLiteralsMatch)
        {
            continue;
        }
        for (auto const & inverted:             clause.second.invertedLiterals)
        {
            // We need to make sure that the tag is either
            // not present or has a mismatched value.
            if (activeTags.contains(inverted->key()))
            {
                if (activeTags[inverted->key()]->text() !=
                    inverted->text())
                {
                    break;
                }
                allLiteralsMatch = false;
                break;
            }
        }
        if (allLiteralsMatch)
        {
            proceed = true;
            break;
        }
    }
    if (not proceed)
    {
        ls.ignore();
    }
    return ls;

逻辑有点复杂,这是一个我发现几乎完全实现逻辑比试图将更改分成多个部分更容易的案例。以下是代码做了什么。因为我们使用 DNF 逻辑,我们可以分别处理每个条款。我们开始时假设我们将继续记录日志,以防没有设置任何过滤器。如果有任何过滤器,那么对于每一个,我们开始时假设我们不会继续。但我们还设置了一个新的bool变量,它假设所有文字都将匹配,直到证明否则。我们将没有文字的条款视为我们应该继续记录日志的信号。

对于检查文字,我们有两种类型:正常和反转。对于正常文字,标签必须在活动标签中全部存在并且具有匹配的值。如果任何标签缺失或具有错误值,那么我们就没有匹配这个条款的所有文字。我们将继续,因为可能还有另一个条款会匹配。这就是我所说的分别处理每个条款的意思。

假设我们已经匹配了所有正常文字,我们仍然需要检查反转文字。在这里,逻辑被反转了,我们需要确保标签不存在或者它具有错误值。

一旦我们检查完所有条款或找到一个匹配所有文字的条款,代码将进行最后一次检查以确定日志是否应该继续。如果不应该,那么我们将调用ignore,这将阻止日志消息被发送到输出日志文件。

这种方法在调用log函数时根据默认标签和发送到log函数的标签决定是否继续。我们将让调用代码发送所需的所有信息到流中。如果未调用ignore,则信息才会完整地到达输出日志文件。

现在一切都可以构建和运行了,我们再次通过了所有测试,如下所示:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Message can be tagged in log
Passed
------- Test: log needs no namespace when used with LogLevel
Passed
------- Test: Default tags set in main appear in log
Passed
------- Test: Multiple tags can be used in log
Passed
------- Test: Tags can be streamed to log
Passed
------- Test: Tags can be used to filter messages
Passed
------- Test: Overridden default tag not used to filter messages
Passed
------- Test: Simple message can be logged
Passed
------- Test: Complicated message can be logged
Passed
-----------------------------------
Tests passed: 9
Tests failed: 0

这表明过滤功能正在工作!至少,对于标签的相等性。测试一个标签是否存在并且具有匹配的值是一个好的开始,但我们的微服务开发者将需要比这更多的能力。也许我们只需要在计数标签的值大于 100 或涉及比较指定过滤器值的数值更大或更小的其他比较时记录日志。这就是我说我几乎完全实现了过滤逻辑的意思。我得到了逻辑以及所有循环和中断,用于标签的相等性。我们应该能够在下一节中相对比较中使用相同的基本代码结构。

在我们开始相对比较之前,还有一件事要补充,这是很重要的。每当添加代码,就像我添加 DNF 逻辑那样,没有测试来支持它,我们需要添加一个测试。否则,遗漏的测试可能会被推迟,直到完全忘记。

这个新测试以另一种方式提供了帮助。它捕捉到了addFilterLiteral函数初始定义中的一个问题。原始函数定义了一个名为invertbool参数,其默认值为false。默认值意味着创建一个普通字面量时可以省略该参数并使用默认值。但为了创建一个倒置字面量,该函数要求传递true值。这在我看来似乎是反过来的。我意识到,传递false给这个参数以获取倒置字面量,而true应该创建一个普通字面量,这样会更有意义。因此,我回过头去修改了函数的定义和实现。测试捕捉到了一个最初未被注意到的函数使用问题。

这里是创建倒置过滤器的新测试:

TEST("Inverted tag can be used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(), green, false);
    std::string message = "inverted ";
    message += Util::randomString();
    MereMemo::log(info) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
}

构建和运行显示新测试通过,并且我们已经确认可以过滤包含匹配标签的日志消息,当过滤器被倒置时。这个测试使用了默认的green标签,该标签被添加到日志消息中,并确保由于存在green标签,日志消息不会出现在输出日志文件中。

下一节将增强过滤功能,允许根据标签的相对值而不是仅基于精确匹配进行过滤。

加强相对匹配的过滤

TDD 鼓励在设计软件时进行增量更改和增强。编写一个测试,让某物工作,然后编写一个更详细的测试来增强设计。我们一直在遵循 TDD 方法来设计日志库,上一节就是一个很好的例子。我们在上一节中实现了过滤功能,但仅限于标签相等。

换句话说,我们现在可以根据标签是否存在来过滤日志消息,这些标签与过滤字面量标签匹配。我们比较标签以查看键和值是否匹配。这是一个很好的第一步,因为即使达到这一步也需要大量的工作。想象一下,如果我们试图做到极致,并支持例如,只有当计数标签的值大于 100 时才进行日志记录。

当使用 TDD(测试驱动开发)设计软件时,在采取下一步之前寻找显而易见的步骤并确认其工作情况非常有帮助。有些步骤可能比其他步骤大,但这没关系,只要你不直接跳到最终实现,因为那样只会导致更长的开发时间和更多的挫败感。确认设计的一些部分按预期工作并拥有确保这些部分继续工作的测试要好的多。这就像建造一座房子,有一个坚实的基础。在建造墙壁之前,确保基础确实牢固要好的多,你希望有测试来确保在添加屋顶时墙壁保持笔直。

我们已经实施了工作测试以确保基本过滤功能正常。我们正在测试正常和倒置的文本。我们通过比较标签的文本来检查匹配的标签,这对于所有值类型都适用。对于像计数大于 100 这样的相对过滤器,我们需要一个能够用数值检查而不是字符串匹配来比较值的解决方案。

我们可以先找出如何表示一个过滤器文本来检查大于或小于的数值。以下是一个可以放入Tags.cpp的测试,它基于计数大于 100 设置一个过滤器:

TEST("Tag values can be used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(),
        Count(100, MereMemo::TagOperation::GreaterThan));
    std::string message = "values ";
    message += Util::randomString();
    MereMemo::log(Count(1)) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
    MereMemo::log() << Count(101) << message;
    result = Util::isTextInFile(message, "application.log");
    CONFIRM_FALSE(result);
    MereMemo::log(Count(101)) << message;
    result = Util::isTextInFile(message, "application.log");
    CONFIRM_TRUE(result);
}

这个测试有什么新内容?主要部分是Count标签的创建方式。我们之前在创建标签时只添加了一个值,如下所示:

Count(100)

由于我们现在需要一种指定是否应该具有相对值的方法,我们需要一个地方来说明相对值的类型以及一个方法来传达要使用的相对值。我认为各种相对比较的枚举应该可以工作。我们可能不需要更高级的相对比较,如"between",因为我们总是可以使用 DNF 来表示更复杂的比较。有关我们如何使用 DNF 的简要概述,请参阅本章的设计测试以过滤日志消息部分。

在标签级别,我们真正需要知道的是如何比较一个值与另一个值。因此,在构建标签时指定所需的比较类型是有意义的,如下所示:

Count(100, MereMemo::TagOperation::GreaterThan)

将具有比较运算符(如GreaterThan)的标签视为完全不同的类型可能是有意义的,但我认为我们可以通过单一类型来解决这个问题。在这个解决方案中,任何标签都可以有比较运算符,但只有当标签将用于过滤器时,指定比较运算符才有意义。

如果在过滤器中使用不带比较运算符的常规标签会发生什么?那么,我们应该将其视为精确匹配,因为这是现有测试所期望的。

回到新的测试。它首先创建了一个过滤器,该过滤器只允许具有计数标签且其值大于 100 的消息被记录。它首先尝试记录一个计数为 1 的消息,并验证该消息不存在于日志文件中。

然后,测试创建了一个计数为 101 的计数器,但并没有在log函数调用中直接使用计数标签。这也应该不会出现在输出日志文件中,因为我们只想在调用log时过滤默认或直接指定的标签。

最后,测试使用计数标签 101 调用log,并验证该消息是否出现在日志文件中。

现在我们有了测试,我们将如何让它工作?让我们首先在Log.h中定义比较操作,在TagType类之前,如下所示:

enum class TagOperation
{
    None,
    Equal,
    LessThan,
    LessThanOrEqual,
    GreaterThan,
    GreaterThanOrEqual
};

我们将使用None操作来表示只想表达值的常规标签。Equal操作将像标签之间的现有相等检查一样起作用。真正的变化是支持小于、小于等于、大于和大于等于的比较。

我们需要比较一个标签与另一个标签,而不必担心标签代表什么。一个好的方法是在Tag类中声明一个纯虚拟方法,就像我们为克隆所做的那样。新方法被称为match,可以紧接在clone方法之后,如下所示:

    virtual std::unique_ptr<Tag> clone () const = 0;
    virtual bool match (Tag const & other) const = 0;

这里事情变得有些困难。我原本想将所有内容都封装在TagType类中。想法是首先检查每个被比较的标签的键,并确保标签是相同的。如果它们有相同的键,那么检查值。如果它们没有相同的键,那么它们肯定不匹配。至少,这是一个不错的计划。当我尝试在一个可以比较字符串与字符串、数字与数字以及布尔值与布尔值的地方实现match方法时遇到了问题。例如,像CacheHit这样的标签有一个bool类型的值,唯一有意义的操作是Equal比较。基于字符串的标签需要与数字进行比较。如果我们真的想做得更细致,双精度浮点数应该与int类型进行比较不同。

每个派生标签类型都可以知道如何比较,但我不想改变派生类型并让它们各自实现match方法,尤其是在我们费了很大力气避免派生类型实现clone之后。我想出的最佳解决方案是创建一组额外的中间类,这些类从TagType派生。每个新类基于值的类型。由于我们只支持五种不同的标签值类型,这不是一个坏解决方案。主要好处是调用者将使用的派生标签类型仅略有影响。这里有一个新的StringTagType类,它从TagType继承,以便你可以看到我的意思。将这个新类放在Log.h中,紧接在TagType类之后:

template <typename T>
class StringTagType : public TagType<T, std::string>
{
protected:
    StringTagType (std::string const & value,
        TagOperation operation)
    : TagType<T, std::string>(value, operation)
    { }
    bool compareTagTypes (std::string const & value,
        TagOperation operation,
        std::string const & criteria) const override
    {
        int result = value.compare(criteria);
        switch (operation)
        {
        case TagOperation::Equal:
            return result == 0;
        case TagOperation::LessThan:
            return result == -1;
        case TagOperation::LessThanOrEqual:
            return result == 0 || result == -1;
        case TagOperation::GreaterThan:
            return result == 1;
        case TagOperation::GreaterThanOrEqual:
            return result == 0 || result == 1;
        default:
            return false;
        }
    }
};

这个类完全是关于比较基于字符串的标签与其他基于字符串的标签。该类实现了一个新虚拟方法,我将在稍后解释,称为compareTagTypes。这个方法唯一需要担心的是如何根据操作比较两个字符串。其中一个字符串被称为value,另一个被称为criteria。重要的是不要混淆valuecriteria字符串,因为例如,虽然"ABC"大于"AAA",但反过来并不成立。该方法使用std::string类中的compare方法来进行比较。

你可以看到StringTagType类从TagType继承,并传递派生类型T,同时为值类型硬编码std::string。关于构造函数的一个有趣之处在于,在构造函数初始化列表中构造TagType时需要重复模板参数。通常情况下,这不应该需要,但也许有一些我尚未意识到的神秘规则仅适用于此处,即编译器不会查看父类列表中的TagType参数来找出模板参数。

在继续到TagType的更改之前,让我们看看派生标签类如LogLevel将如何使用新的StringTagType中间类。将LogLevel类修改如下:

class LogLevel : public StringTagType<LogLevel>
{
public:
    static constexpr char key[] = "log_level";
    LogLevel (std::string const & value,
        TagOperation operation = TagOperation::None)
    : StringTagType(value, operation)
    { }
};

对于LogLevel所需的唯一更改是将父类从TagType更改为更具体的StringTagType。我们不再需要担心指定std::string作为模板参数,因为该信息已内置到StringTagType类中。我原本想保持派生标签类完全不变,但这种轻微的修改并不糟糕,因为不需要编写任何比较代码。

TagType类中还有更多工作要做。在TagType类末尾的保护部分,进行以下更改:

protected:
    TagType (ValueT const & value,
        TagOperation operation)
    : Tag(T::key, value), mValue(value), mOperation(operation)
    { }
    virtual bool compareTagTypes (ValueT const & value,
        TagOperation operation,
        ValueT const & criteria) const
    {
        return false;
    }
    ValueT mValue;
    TagOperation mOperation;
};

受保护的构造函数需要存储操作,这就是声明虚拟compareTagTypes方法并为其提供一个默认实现(返回false)的地方。TagType类还实现了在Tag类中声明的match方法,如下所示:

    bool match (Tag const & other) const override
    {
        if (key() != other.key())
        {
            return false;
        }
        TagType const & otherCast =                 static_cast<TagType const &>(other);
        if (mOperation == TagOperation::None)
        {
            switch (otherCast.mOperation)
            {
            case TagOperation::None:
                return mValue == otherCast.mValue;
            default:
                return compareTagTypes(mValue,
                    otherCast.mOperation,
                    otherCast.mValue);
            }
        }
        switch (otherCast.mOperation)
        {
        case TagOperation::None:
            return compareTagTypes(otherCast.mValue,
                mOperation,
                mValue);
        default:
            return false;
        }
    }

match方法首先检查键,看看被比较的两个标签是否具有相同的键。如果键匹配,则假设类型相同,并将另一个标签转换为相同的TagType

我们有几个场景需要确定。至少有一个标签应该是一个没有操作的正常标签,我们将称之为值。另一个标签也可以是一个没有操作的常规标签,在这种情况下,我们只需要比较两个值是否相等。

如果两个标签中有一个是正常的,而另一个有除了None之外的比较操作,那么设置了比较运算符的标签被视为标准。记住,知道哪个是值,哪个是标准是很重要的。代码需要处理比较值与标准或比较标准与值的情况。我们调用虚拟的compareTagTypes方法来进行实际比较,确保根据哪个是正常标签和哪个是标准,传递mValueotherCast.mValue

最后,如果两个标签的比较运算符都设置为除None之外的内容,那么我们将匹配视为false,因为比较两个标准标签之间没有意义。

match 方法中,我想要实现的部分有点复杂性,这个方法只在一个地方实现。这就是为什么我决定保留 TagType 类并创建特定于值类型的中间类,如 StringTagTypeTagType 类通过确定正在比较什么以及什么与什么进行比较来实现部分比较,然后依赖于特定类型的类来完成实际的比较。

我们需要添加其他特定类型的中间标签类。所有这些都在 Log.h 文件中,紧接在 StringTagType 类之后。以下是 int 类型的示例:

template <typename T>
class IntTagType : public TagType<T, int>
{
protected:
    IntTagType (int const & value,
        TagOperation operation)
    : TagType<T, int>(value, operation)
    { }
    bool compareTagTypes (int const & value,
        TagOperation operation,
        int const & criteria) const override
    {
        switch (operation)
        {
        case TagOperation::Equal:
            return value == criteria;
        case TagOperation::LessThan:
            return value < criteria;
        case TagOperation::LessThanOrEqual:
            return value <= criteria;
        case TagOperation::GreaterThan:
            return value > criteria;
        case TagOperation::GreaterThanOrEqual:
            return value >= criteria;
        default:
            return false;
        }
    }
};

这个类几乎与 StringTagType 类相同,只是针对 int 类型进行了更改,而不是字符串。主要区别在于比较可以使用简单的算术运算符来完成,而不是调用字符串的 compare 方法。

我考虑过将这个类用于所有的 intlong longdouble 算术类型,但这意味着它仍然需要一个模板参数来指定实际类型。那么,问题就变成了一致性。StringTagType 类也应该有一个模板参数来指定字符串的类型吗?也许吧。因为存在不同类型的字符串,所以这几乎是有道理的。但关于 bool 类型怎么办?我们还需要一个中间类来处理布尔值,当类名中已经包含 bool 时,指定一个 bool 模板类型似乎很奇怪。因此,为了保持一切的一致性,我决定为所有支持的类型使用单独的中间类。我们将使用 IntTagType 类来处理整数,并创建另一个名为 LongLongTagType 的类,如下所示:

template <typename T>
class LongLongTagType : public TagType<T, long long>
{
protected:
    LongLongTagType (long long const & value,
        TagOperation operation)
    : TagType<T, long long>(value, operation)
    { }
    bool compareTagTypes (long long const & value,
        TagOperation operation,
        long long const & criteria) const override
    {
        switch (operation)
        {
        case TagOperation::Equal:
            return value == criteria;
        case TagOperation::LessThan:
            return value < criteria;
        case TagOperation::LessThanOrEqual:
            return value <= criteria;
        case TagOperation::GreaterThan:
            return value > criteria;
        case TagOperation::GreaterThanOrEqual:
            return value >= criteria;
        default:
            return false;
        }
    }
};

我对这个类不是很满意,因为它与整数的实现完全相同。但让我高兴的是它创造了一致性。这意味着所有中间的标签类型类都可以以相同的方式使用。

下一个类是用于 double 的,尽管它也有相同的实现,但由于它们不像整型那样比较,所以有潜力以不同的方式比较双精度浮点数。在浮点值之间总是存在一点误差和细微的差异。目前,我们不会对双精度浮点数做任何不同的事情,但这个类将使我们能够在需要时以不同的方式比较它们。这个类的样子如下:

template <typename T>
class DoubleTagType : public TagType<T, double>
{
protected:
    DoubleTagType (double const & value,
        TagOperation operation)
    : TagType<T, double>(value, operation)
    { }
    bool compareTagTypes (double const & value,
        TagOperation operation,
        double const & criteria) const override
    {
        switch (operation)
        {
        case TagOperation::Equal:
            return value == criteria;
        case TagOperation::LessThan:
            return value < criteria;
        case TagOperation::LessThanOrEqual:
            return value <= criteria;
        case TagOperation::GreaterThan:
            return value > criteria;
        case TagOperation::GreaterThanOrEqual:
            return value >= criteria;
        default:
            return false;
        }
    }
};

最后一个中间标签类型类是用于布尔值的,它确实需要做一些不同的事情。这个类实际上只对相等性感兴趣,其样子如下:

template <typename T>
class BoolTagType : public TagType<T, bool>
{
protected:
    BoolTagType (bool const & value,
        TagOperation operation)
    : TagType<T, bool>(value, operation)
    { }
    bool compareTagTypes (bool const & value,
        TagOperation operation,
        bool const & criteria) const override
    {
        switch (operation)
        {
        case TagOperation::Equal:
            return value == criteria;
        default:
            return false;
        }
    }
};

现在我们已经解决了所有标签的问题,需要进行比较的地方是在 log 函数中,该函数目前使用标签的文本来比较正常和反转的标签。将 normal 块更改为如下所示:

        for (auto const & normal: clause.second.normalLiterals)
        {
            // We need to make sure that the tag is
            // present and with the correct value.
            if (not activeTags.contains(normal->key()))
            {
                allLiteralsMatch = false;
                break;
            }
            if (not activeTags[normal->key()]->match(*normal))
            {
                allLiteralsMatch = false;
                break;
            }
        }

代码仍然遍历标签并检查涉及的键是否存在。一旦发现标签存在并且需要比较,代码现在将调用 match 方法,而不是获取每个标签的文本并比较它们是否相等。

反转块需要以类似的方式进行更改,如下所示:

        for (auto const & inverted:             clause.second.invertedLiterals)
        {
            // We need to make sure that the tag is either
            // not present or has a mismatched value.
            if (activeTags.contains(inverted->key()))
            {
                if (activeTags[inverted->key()]->match(                   *inverted))
                {
                    allLiteralsMatch = false;
                }
                break;
            }
        }

对于反转循环,我能够稍微简化一下代码。真正的变化与正常循环类似,其中调用match方法进行比较,而不是直接比较标签文本。

在我们能够构建和尝试新的测试之前,我们需要更新测试应用中其他派生标签类型。就像我们需要更新LogLevel标签类以使用新的中间标签类一样,我们需要更改LogTags.h中的所有标签类。首先是Color类,如下所示:

class Color : public MereMemo::StringTagType<Color>
{
public:
    static constexpr char key[] = "color";
    Color (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};

Color类基于字符串值类型,就像LogLevel

Size标签类型也使用字符串,现在看起来像这样:

class Size : public MereMemo::StringTagType<Size>
{
public:
    static constexpr char key[] = "size";
    Size (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};

CountIdentity标签类型分别基于int类型和long long类型,它们看起来像这样:

class Count : public MereMemo::IntTagType<Count>
{
public:
    static constexpr char key[] = "count";
    Count (int value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : IntTagType(value, operation)
    { }
};
class Identity : public MereMemo::LongLongTagType<Identity>
{
public:
    static constexpr char key[] = "id";
    Identity (long long value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : LongLongTagType(value, operation)
    { }
};

最后,ScaleCacheHit标签类型分别基于double类型和bool类型,它们看起来像这样:

class Scale : public MereMemo::DoubleTagType<Scale>
{
public:
    static constexpr char key[] = "scale";
    Scale (double value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : DoubleTagType(value, operation)
    { }
};
class CacheHit : public MereMemo::BoolTagType<CacheHit>
{
public:
    static constexpr char key[] = "cache_hit";
    CacheHit (bool value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : BoolTagType(value, operation)
    { }
};

每个标签类型的更改都很小。我认为这是可以接受的,尤其是因为使用标签类型的测试不需要更改。让我们再次看看开始这个部分的测试:

TEST("Tag values can be used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(),
        Count(100, MereMemo::TagOperation::GreaterThan));
    std::string message = "values ";
    message += Util::randomString();
    MereMemo::log(Count(1)) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
    MereMemo::log() << Count(101) << message;
    result = Util::isTextInFile(message, "application.log");
    CONFIRM_FALSE(result);
    MereMemo::log(Count(101)) << message;
    result = Util::isTextInFile(message, "application.log");
    CONFIRM_TRUE(result);
}

这个测试现在应该更有意义。它创建了一个值为100Count标签和一个TagOperation标签为GreaterThan。操作使得这个标签成为了一个可以与其他Count标签实例进行比较的准则标签,以查看其他实例中的计数是否真的大于 100。

然后,测试尝试使用具有值为1的正常Count标签进行日志记录。我们现在知道这将如何失败匹配,并且日志消息将被忽略。

测试随后尝试使用101Count标签进行日志记录,但这次标签位于log函数外部,将不会被考虑。第二个日志消息也将被忽略,而不会尝试调用match

测试随后尝试在log函数内部使用101个计数进行日志记录。这个应该匹配,因为 101 确实大于 100,并且消息应该出现在输出日志文件中。

注意测试的结构。它从几个已知场景开始,这些场景不应该成功,最终过渡到一个应该成功的场景。当你编写测试时,这是一个很好的模式,有助于确认一切按设计工作。

现在过滤功能即使在相对比较的情况下也能完全工作!本章的其余部分将提供见解和建议,以帮助您设计更好的测试。

何时测试过多?

我记得曾经听到过一个关于一个孩子在重症监护室的故事,他连接着所有的监测机器,包括一个监测心跳电信号的机器。孩子的状况突然恶化,显示出大脑血流不足的所有迹象。医生们无法理解为什么会这样,因为心跳还在,他们正准备将孩子送去扫描以寻找可能导致中风的血凝块,这时一位医生想到要听一下心跳。没有声音。机器显示心跳还在,但没有声音来确认心跳。医生们能够确定心脏周围的水肿正在对心脏施加压力,阻止它跳动。我不知道他们是怎么做到的,但他们减少了肿胀,孩子的心脏又开始泵血了。

为什么这个故事会浮现在脑海中?因为监测心脏活动的机器正在寻找电信号。在正常情况下,适当的电信号是监测心脏活动的好方法。但它是不直接的。电信号是心脏跳动的方式。信号导致心脏跳动,但正如故事所示,它们并不总是意味着心脏真的在跳动

很容易在软件测试中陷入同样的陷阱。我们以为因为我们有很多测试,所以软件一定经过了很好的测试。但测试真的测试了正确的事情吗?换句话说,每个测试都在寻找有形的结果吗?或者有些测试只是在看结果通常是如何获得的?

何时测试过多?我的答案是测试是好的,你添加的每个测试通常都会帮助提高软件的质量。如果测试开始关注错误的事情,它就会变得过多。

并非一个关注错误事情的测试是坏的。坏的部分在于我们依赖于这个测试来预测某些结果。直接确认期望的结果比确认过程中的某个内部步骤要好得多。

例如,看看最近添加了一个filter字面量的测试:

TEST("Tag values can be used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(),
        Count(100, MereMemo::TagOperation::GreaterThan));

我们本可以验证过滤器确实被添加到了收集中。我们可以在测试中调用getFilterClauses函数,检查每个子句,寻找刚刚添加的字面量。我们甚至可以确认这个字面量本身的行为符合预期,并且将值100分配给了这个字面量。

测试并不这样做。为什么?因为这就是过滤器的工作方式。在收集过程中寻找过滤器就像观察心跳电信号一样。能够调用getFilterClauses的能力仅仅是因为我们希望将日志库包含在一个单独的头文件中。这个函数并不是打算由客户调用的。测试反而检查设置过滤器的结果。

一旦设置好过滤器,测试就会尝试记录一些消息,并确保结果符合预期。

如果日志库需要某种自定义集合,那么测试过滤字面量是否正确添加到集合中是否有意义呢?我的回答是,不,至少在这里的过滤测试中不是这样。

如果项目需要自定义集合,那么它需要测试以确保集合能够正常工作。我并不是说因为代码在项目中的支持角色而跳过任何需要编写的代码的测试。我想要表达的是,要使测试集中在它们要测试的内容上。测试试图确认的期望结果是什么?在过滤测试的情况下,期望的结果是某些日志消息将被忽略,而其他消息将出现在输出日志文件中。测试直接设置确认结果所需的条件,执行必要的步骤,并确认结果。在这个过程中,集合和所有匹配的代码也将以间接方式得到测试。

如果涉及到自定义集合,那么间接测试是不够的。但在过滤器测试中进行直接测试也是不合适的。我们需要的是一组设计用来直接测试自定义集合本身的测试。

因此,如果我们需要像自定义集合这样的支持组件,那么这个组件需要单独进行测试。这些测试可以包含在同一个整体测试应用程序中。也许可以将它们放入自己的测试套件中。考虑一下将作为组件客户的代码,并考虑客户的需求。

如果组件足够大或具有更通用的用途,以至于它可能在项目之外也有用,那么给它一个单独的项目是一个好主意。这就是我们在本书中将单元测试库和日志库视为独立项目所做的事情。

关于过度测试的最后一想法将帮助您识别何时处于这种情况,因为很容易滑入过度间接测试。如果您发现重构软件工作方式后需要更改大量测试,那么您可能测试得太多了。

考虑一下这一章是如何添加过滤器并能够几乎完全不变地保留现有测试的。当然——我们不得不通过添加一系列中间标签类型类来更改底层的代码,但我们不需要重写现有的测试。

如果重构导致测试也需要大量工作,那么要么是你测试得太多,要么可能是问题在于你正在改变软件的预期使用方式。注意改变你希望设计如何被使用的方式,因为如果你遵循 TDD,那么最初的使用方式是你想要首先做对的事情之一。一旦你以使软件易于使用和直观的方式设计了软件,那么在重构可能改变测试的情况下要格外小心。

下一个部分解释了与这一部分相关的一个主题。一旦你知道需要测试什么,接下来经常出现的问题是如何设计软件使其易于测试,特别是测试是否需要深入到被测试组件的内部工作原理。

测试应该有多侵入性?

设计易于测试的软件是有好处的。对我来说,这始于遵循 TDD(测试驱动开发)并首先编写测试,这些测试利用软件以客户最期望的方式使用。这是最重要的考虑因素。

你不希望你的软件用户质疑为什么需要额外的步骤,或者为什么难以理解如何使用你的软件。在这里,客户或用户指的是任何将使用你的软件的人。客户或用户可能是一个需要使用正在设计的库的软件开发者。测试是用户必须经历的很好的例子。如果用户必须采取的额外步骤对用户没有任何价值,那么应该移除这个步骤,即使这个步骤使得测试代码更容易进行。

额外的步骤可能可以隐藏起来,如果可以的话,那么只要它使测试变得更好,就有可能保留它。任何时候测试依赖于用户不需要或不知道的额外内容,那么测试就是在侵犯软件设计。

我并不是说这是坏事。侵入通常有负面含义。只要你知道这会使你容易陷入前述章节描述的陷阱:过度测试,测试能够深入到组件内部对测试来说可能是好事。

需要理解的主要一点是,任何测试所使用的都应该成为支持接口的一部分。如果一个组件暴露了内部工作原理以便测试可以确认,那么这个内部工作原理应该被视为设计的一部分,而不是任何可以随时更改的内部细节。

本节所描述的内容与上一节之间的区别在于什么被同意支持。当我们试图测试那些应该在其他地方测试或内部细节且应该对测试不可达的事物时,我们会进行过多的测试。如果一个内部细节是稳定的,并且被同意不应该改变,并且如果这个内部细节使测试更可靠,那么测试使用这个细节可能是合理的。

我记得多年前参与的一个项目,该项目通过可扩展标记语言XML)暴露了类的内部状态。有时状态可能相当复杂,使用 XML 可以让测试确认状态配置正确。然后,XML 会被传递给其他使用它的类。用户对 XML 并不知情,也不需要使用它,但测试依赖于它来将复杂的场景一分为二。测试的一半可以通过验证 XML 匹配来确保配置正确。然后另一半可以确保在提供已知 XML 输入数据时采取的行动是正确的。

软件并不需要按照这种方式设计才能使用 XML。甚至可以说,测试侵入了设计。XML 成为了设计的一部分。原本可能只是细节的东西变成了更多。但我还会进一步说,在这种情况下使用 XML 从来不是作为一个细节开始的。这是一个有意识的设计决策,是为了特定的原因——使测试更加可靠。

到目前为止,我们只探讨了单元测试。这就是为什么这本书从构建单元测试库开始。在考虑应该测试什么以及测试应该有多侵入性时,下一节将开始解释其他类型的测试。

在 TDD 中,集成或系统测试放在哪里?

有时候,创建一个将多个组件组合在一起并确认正在构建的整体系统按预期工作的测试是很有好处的。这些被称为集成测试,因为它们将多个组件集成在一起以确保它们能够良好地协同工作。或者,这些测试也可以被称为系统测试,因为它们测试整个系统。这两个名称在大多数情况下是可以互换的。

对于我们的微服务开发者,他们是日志库的目标客户,他们可能会为单个服务编写单元测试,甚至为服务内部的各种类和函数编写单元测试。特定服务的某些测试甚至可能被称为集成测试,但通常,集成测试将与多个服务一起工作。服务应该协同工作以完成更大的任务。因此,确保整体结果可以实现的测试将有助于提高所有参与服务的可靠性和质量。

如果你不是在构建一组微服务呢?如果你正在构建一个桌面应用程序来管理加密货币钱包呢?你仍然可以使用系统测试。也许你想要一个系统测试,它可以打开一个新的钱包并确保它能够同步到当前区块的区块链数据,或者也许你想要另一个系统测试,它停止同步然后再重新开始。每个这样的测试都将使用许多不同的组件,例如应用程序中的类和函数。系统测试确保能够实现某些高级目标,更重要的是,系统测试使用通过网络下载的真实数据。

系统测试通常需要很长时间才能完成。加上多个系统测试,整个测试集可能需要几个小时才能运行。或者,也许有一些测试会连续使用软件一天或更长时间。

一个特定的测试是否被称为单元测试或系统测试,通常取决于其运行所需的时间以及所需的资源。单元测试通常运行得很快,能够确定某事物是否通过,而无需依赖其他外部因素或组件。如果一个测试需要从另一个服务请求信息,那么这通常是一个很好的迹象,表明这个测试更像是集成测试而不是单元测试。单元测试不应该需要从网络上下载数据。

当谈到 TDD 时,为了使测试真正驱动设计——正如其名称所暗示的——那么测试通常将是单元测试类型。请别误会——系统测试很重要,可以帮助发现单元测试可能错过的奇怪使用模式。但典型的系统测试或集成测试并不是为了确保设计易于使用且直观。相反,系统测试确保能够达到高级目标,并且没有任何东西会破坏最终目标。

如果系统测试和集成测试之间有任何区别,那么在我看来,它归结为集成测试主要是确保多个组件能够良好地协同工作,而系统测试则更多地关注高级目标。集成测试和系统测试都高于单元测试。

TDD 在创建小型组件和函数的初始设计时更多地使用了单元测试。然后,TDD 利用系统测试和集成测试来确保整体解决方案合理且运行正常。

你可以将我们对日志库进行的所有测试都视为单元测试库的系统测试。我们确保单元测试库实际上可以帮助设计另一个项目。

至于系统或集成测试放在哪里,它们通常属于不同的测试项目——可以独立运行的项目。这甚至可以是一个脚本。如果你把它们放在与单元测试相同的测试项目中,那么就需要有一种方法来确保在需要快速响应时只运行单元测试。

除了系统和集成测试之外,还有更多测试你可能想要考虑添加。下一节将描述更多类型的测试。

那么,其他类型的测试又如何呢?

还有更多类型的测试需要考虑,例如性能测试、负载测试和渗透测试。你甚至可以涉及到可用性测试、升级测试、认证测试、持续运行测试等等,包括我可能从未听说过的类型。

每种测试类型都有对软件开发有价值的用途。每种类型都有自己的流程和步骤,测试的运行方式以及验证成功的方式。

性能测试可能会选择一个特定的场景,比如加载一个大文件,并确保操作可以在一定时间内完成。如果测试还检查确保操作仅使用一定量的计算机内存或 CPU 时间即可完成,那么在我看来,它开始更像是一个负载测试。而且如果测试确保最终用户不需要等待或被通知延迟,那么它开始更像是一个可用性测试。

测试类型之间的界限有时并不清晰。前一个章节已经解释了系统测试和集成测试通常是同一件事,有一个细微的区别,通常并不重要。其他测试也是如此。例如,一个特定的测试是负载测试还是性能测试,通常取决于意图。测试是否试图确保操作在特定时间内完成?谁决定什么时间足够好?或者,测试是否试图确保操作可以在同时进行其他事情时完成?或者,也许对于加载大文件的测试,一个几兆字节的大文件用于性能测试,因为这是客户可能遇到的一个典型的大文件,而负载测试会尝试加载一个更大的文件。这些只是一些想法。

渗透测试略有不同,因为它们通常作为官方安全审查的一部分创建。整个软件解决方案将被分析,产生大量文档,并创建测试。渗透测试通常试图确保在提供恶意数据或系统被滥用时,软件不会崩溃。

其他渗透测试将检查信息泄露。是否有滥用软件的可能性,使得攻击者获得本应保持机密的知识?

更重要的是渗透测试,它可以捕捉到数据操纵。一个常见的例子是学生试图更改他们的成绩,但这种攻击可以用来窃取金钱或删除关键信息。

提权攻击对于防止渗透测试至关重要,因为它们让攻击者获得可以导致更多攻击的访问权限。当攻击者能够控制远程服务器时,这显然是一种提权,但提权可以用来获得攻击者通常没有的任何额外权限或能力。

可用性测试更加主观,通常涉及客户访谈或试用。

所有不同类型的测试都很重要,我的目标不是列出或描述所有可能的测试类型,而是给你一个关于可用的测试类型以及不同测试可以提供哪些益处的概念。

软件测试不是关于使用哪种测试的问题,而是每种类型在过程中的位置。关于每种测试类型都可以写一本书,而且已经有很多这样的书。这本书之所以如此专注于单元测试,是因为单元测试与 TDD 过程最为接近。

摘要

TDD 过程比本章中添加到日志库的功能更为重要。我们添加了日志级别、标签和过滤功能,甚至重构了日志库的设计。虽然所有这些都是有价值的,但最重要的还是要关注涉及的过程。

本章之所以如此详细,是为了让你看到所有设计决策以及测试是如何在整个过程中起到指导作用的。你可以将这种学习应用到自己的项目中。如果你也使用日志库,那么这将是额外的收获。

你学习了理解客户需求的重要性。客户不一定是走进商店买东西的人。客户是正在开发的软件的预期用户。这甚至可以是另一个软件开发者或公司内的另一个团队。理解预期用户的需求将使你能够编写更好的测试来解决这些需求。

编写一个看似合适的函数或设计一个接口非常容易,但后来发现很难使用。先编写测试可以帮助避免使用问题。在本章中,你看到了一个我仍然需要回去更改函数工作方式的地方,因为测试显示它存在逆向问题。

需要支持按值过滤日志消息的广泛更改,而本章展示了如何在保持测试不变的情况下进行更改。

理解 TDD 的最好方法是在项目中使用这个过程。本章为日志库开发了很多新代码,让你能够近距离观察这个过程,并提供了比简单示例所能展示的更多内容。

下一章将探讨依赖关系,并将日志库扩展到向多个日志文件目的地发送日志消息。

第十一章:管理依赖关系

识别依赖关系并在依赖关系使用的公共接口周围实现您的代码将有助于您以多种方式。您将能够做到以下事情:

  • 避免等待其他团队或甚至自己完成复杂且必要的组件

  • 将您的代码隔离并确保其正常工作,即使您使用的其他代码中存在错误

  • 通过您的设计实现更大的灵活性,以便您只需更改依赖组件即可更改行为

  • 创建接口,清晰地记录并突出关键要求

在本章中,您将了解依赖关系是什么以及如何设计您的代码来使用它们。到本章结束时,您将了解如何更快地完成代码编写并证明其工作,即使项目的其余部分尚未准备好也是如此。

您不需要使用 TDD 来设计和使用依赖关系。但如果您正在使用 TDD,那么整个过程将变得更好,因为您还将能够编写更好的测试,这些测试可以专注于代码的特定区域,而无需担心来自代码外部的额外复杂性和错误。

本章将涵盖以下主要主题:

  • 基于依赖进行设计

  • 添加多个日志输出

技术要求

本章中所有代码都使用基于任何现代 C++ 20 或更高版本编译器和标准库的标准 C++。代码使用了本书第一部分测试 MVP中提到的测试库,并继续开发在前面章节中开始的日志库。

您可以在以下 GitHub 仓库中找到本章所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

)

基于依赖进行设计

依赖关系并不总是显而易见的。如果一个项目使用库,例如日志项目如何使用单元测试库,那么这是一个容易发现的依赖关系。日志项目依赖于单元测试库以正确运行。或者在这种情况下,只有日志测试依赖于单元测试库。但这已经足够形成一个依赖关系。

另一个容易发现的依赖关系是如果您需要调用另一个服务。即使代码在调用之前检查其他服务是否可用,依赖关系仍然存在。

库和服务是外部依赖关系的好例子。您必须做额外的工作才能使项目使用另一个项目的代码或服务,这就是为什么外部依赖关系如此容易被发现。

其他依赖关系更难发现,这些通常是项目内部的内部依赖关系。从某种意义上说,项目中的几乎所有代码都依赖于其他代码正确执行其预期功能。因此,让我们细化一下我们对依赖关系的理解。通常,当提到依赖关系时,与代码设计相关,我们指的是可以交换的东西。

这可能通过外部服务依赖的例子最容易理解。该服务在自己的接口上运行。你使用服务定义的接口,根据其位置或地址向服务发出请求。如果第一个服务不可用,你可以为相同的请求调用不同的服务。理想情况下,两个服务会使用相同的接口,这样你代码需要更改的只有地址。

如果两个服务使用不同的接口,那么为每个服务创建一个包装器可能是有意义的,这个包装器知道如何将每个服务期望的内容翻译成你的代码将使用的通用接口。有了通用接口,你可以交换一个服务为另一个服务,而无需更改代码。你的代码更多地依赖于服务接口定义,而不是任何特定的服务。

如果我们看看内部设计决策,可能有一个基类和一个派生类。派生类肯定依赖于基类,但这种依赖类型不能在不重写代码以使用不同的基类的情况下更改。

当考虑日志库定义的标签时,我们更接近可以替换的依赖。可以定义新的标签并使用它们,而无需更改现有代码。日志库可以使用任何标签,无需担心每个标签的作用。但我们是真的在替换标签吗?对我来说,标签的设计是为了在日志文件中以一致的方式解决日志键=值元素的问题,而不依赖于值的类型。尽管日志库依赖于标签及其使用的接口,但我不会将标签设计归类为与外部服务相同的依赖类型。

在早期思考日志库时,我提到过我们需要能够将日志信息发送到不同的目的地,或者甚至多个目的地。代码使用log函数,并期望它要么被忽略,要么发送到某个地方。将日志消息发送到特定目的地的能力是日志库需要依赖的。日志库应该让执行日志的项目决定目的地。

这就引出了依赖关系的另一个方面。依赖通常是指配置过的某些东西。我的意思是,我们可以这样说,日志库依赖于某些组件来执行将消息发送到目的地的任务。日志库可以被设计成选择自己的目的地,或者日志库可以被告知使用哪个依赖。当我们让其他代码控制依赖时,我们得到的是所谓的依赖注入。当你允许调用代码注入依赖时,你会得到一个更灵活的解决方案。

这里有一些我放入 main 函数中的初始代码,用于配置一个知道如何将日志消息发送到文件的组件,然后将文件组件注入到日志记录器中,以便日志记录器知道将日志消息发送到何处:

int main ()
{
    MereMemo::FileOutput appFile("application.log");
    appFile.maxSize() = 10'000'000;
    appFile.rolloverCount() = 5;
    MereMemo::addLogOutput(appFile);
    MereMemo::addDefaultTag(info);
    MereMemo::addDefaultTag(green);
    return MereTDD::runTests(std::cout);
}

策略是创建一个名为 FileOutput 的类,并给它提供写入日志消息的文件名。因为我们不希望日志文件变得太大,所以我们应该能够指定最大大小。代码使用 1000 万字节作为最大大小。当日志文件达到最大大小时,我们应该停止向该文件写入,并创建一个新的文件。我们应该能够在开始删除旧文件之前指定要创建的日志文件数量。代码将最大日志文件数设置为五个。

一旦创建并配置好我们想要的 FileOutput 实例,就可以通过调用 addLogOutput 函数将其注入到日志库中。

这段代码能满足我们的需求吗?它是否直观且易于理解?尽管这不是一个测试,但我们仍然通过在编写实现新功能的代码之前专注于新功能的用法来遵循 TDD。

至于满足我们的需求,这并不是真正需要问的问题。我们需要问的是它是否能够满足我们目标客户的需求。我们正在设计一个日志库,供微服务开发者使用。可能有数百个服务在服务器计算机上运行,我们真的应该将日志文件放在特定的位置。我们需要的第一个更改是让调用者指定日志文件应该创建的路径。路径似乎应该与文件名分开。

对于文件名,我们将如何命名多个日志文件?它们不能都叫 application.log。文件应该编号吗?它们都将放在同一个目录中,文件系统唯一的要求是每个文件都有一个唯一的名称。我们需要让调用者提供日志文件名的模式,而不是单个文件名。一个模式将让日志库知道如何使名称唯一,同时仍然遵循开发者想要的总体命名风格。我们可以将初始代码更改为如下:

    MereMemo::FileOutput appFile("logs");
    appFile.namePattern() = "application-{}.log";
    appFile.maxSize() = 10'000'000;
    appFile.rolloverCount() = 5;
    MereMemo::addLogOutput(appFile);

在设计一个类时,在构造后让类以合理的默认值工作是一个好主意。对于文件输出,我们需要的最基本的是创建日志文件的目录。其他属性很好,但不是必需的。如果没有提供名称模式,我们可以默认为简单的唯一数字。最大大小可以有一个无限默认值,或者至少一个非常大的数字。我们只需要一个日志文件。因此,轮换计数可以是一个告诉我们使用单个文件的值。

我决定使用简单的花括号 {} 作为模式中的占位符,其中将放置一个唯一的数字。我们将随机选择一个三位数来使日志文件名唯一。这将给我们提供多达一千个日志文件,这应该足够了。大多数用户可能只想保留少数几个,并删除较旧的文件。

因为输出是一个可以被替换的依赖,甚至可以同时有多个输出,那么不同类型的输出会是什么样子呢?我们将在稍后确定输出依赖组件接口。现在,我们只想探索如何使用不同的输出。以下是输出如何发送到 std::cout 控制台的方式:

    MereMemo::StreamOutput consoleStream(std::cout);
    MereMemo::addLogOutput(consoleStream);

控制台输出是一个 ostream,因此我们应该能够创建一个可以与任何 ostream 一起工作的流输出。这个例子创建了一个名为 consoleStream 的输出组件,它可以像文件输出一样添加到日志输出中。

在使用 TDD 时,避免添加可能并非客户真正需要的有趣特性是很重要的。我们不会添加删除输出的功能。一旦输出被添加到日志库中,它将保持不变。为了删除输出,我们可能需要返回某种标识符,以便稍后删除之前添加的相同输出。我们确实添加了删除过滤条件的能力,因为这看起来可能是需要的。对于大多数客户来说,删除输出似乎不太可能。

为了设计一个可以被其他依赖替换的依赖,我们需要一个所有输出都实现的公共接口类。这个类将被命名为 Output,并放置在 Log.h 文件中,紧挨着 LogStream 类之前,如下所示:

class Output
{
public:
    virtual ~Output () = default;
    Output (Output const & other) = delete;
    Output (Output && other) = delete;
    virtual std::unique_ptr<Output> clone () const = 0;
    virtual void sendLine (std::string const & line) = 0;
    Output & operator = (Output const & rhs) = delete;
    Output & operator = (Output && rhs) = delete;
protected:
    Output () = default;
};

接口中仅包含 clonesendLine 方法。我们将遵循与标签类似的克隆模式,但不会使用模板。sendLine 方法将在需要将一行文本发送到输出时被调用。其他方法确保没有人可以直接构造 Output 的实例,或者复制或分配一个 Output 实例到另一个实例。Output 类被设计为可以被继承的。

我们将通过接下来的两个函数来跟踪所有已添加的输出,这两个函数紧随 Output 类之后,如下所示:

inline std::vector<std::unique_ptr<Output>> & getOutputs ()
{
    static std::vector<std::unique_ptr<Output>> outputs;
    return outputs;
}
inline void addLogOutput (Output const & output)
{
    auto & outputs = getOutputs();
    outputs.push_back(output.clone());
}

getOutputs 函数使用一个静态的唯一指针向量,并在请求时返回集合。addLogOutput 函数将给定输出的克隆添加到集合中。这都与默认标签的处理方式相似。

你应该知道的一个有趣的依赖关系的使用是它们能够用一个假组件替换一个真实组件的能力。我们正在添加两个真实组件来管理日志输出。一个将输出到文件,另一个到控制台。但是,如果你想在你代码的进展上取得进展,而正在等待另一个团队完成编写所需的组件时,你也可以使用依赖关系。与其等待,不如将组件设置为依赖关系,你可以用更简单的版本替换它。这个更简单的版本不是真实版本,但它应该更快编写,并让你继续取得进展,直到真实版本可用。

一些其他的测试库将这种假依赖关系的能力更进一步,并允许你用几行代码创建可以以各种方式响应的组件,这些方式你可以控制。这让你可以隔离你的代码,并确保它按预期行为,因为你可以依赖假依赖关系始终按指定方式行为,你也不再需要担心真实依赖关系中的错误会影响测试结果。这些假组件的通用术语是模拟

无论你是使用一个通过几行代码为你生成模拟的测试库,还是你自己编写模拟,都没有关系。任何时候,当你有一个模仿另一个类的类时,你就有了一个模拟。

除了将你的代码与错误隔离之外,模拟还可以帮助你加快测试速度,并改善与其他团队的协作。速度的提高是因为真实代码可能需要花费时间请求或计算结果,而模拟可以快速返回,无需进行任何实际工作。与其他团队的协作得到改善,因为每个人都可以同意简单的模拟,这些模拟易于开发,可以用来传达设计变更。

下一节将实现基于通用接口的文件和流输出类。我们将能够简化 LogStream 类和 log 函数,以使用通用接口,这将记录并使理解发送日志消息到输出的真正需求变得更加容易。

添加多个日志输出

验证设计是否适用于多种场景的一个好方法是实现每个场景的解决方案。我们有一个通用的 Output 接口类,它定义了两个方法,clonesendLine,我们需要确保这个接口将适用于将日志消息发送到日志文件和到控制台。

让我们从继承自 Output 的一个名为 FileOutput 的类开始。新类放在 Log.h 中,紧接在 getOutputsaddLogOutput 函数之后,如下所示:

class FileOutput : public Output
{
public:
    FileOutput (std::string_view dir)
    : mOutputDir(dir),
    mFileNamePattern("{}"),
    mMaxSize(0),
    mRolloverCount(0)
    { }
    FileOutput (FileOutput const & rhs)
    : mOutputDir(rhs.mOutputDir),
    mFileNamePattern(rhs.mFileNamePattern),
    mMaxSize(rhs.mMaxSize),
    mRolloverCount(rhs.mRolloverCount)
    { }
    FileOutput (FileOutput && rhs)
    : mOutputDir(rhs.mOutputDir),
    mFileNamePattern(rhs.mFileNamePattern),
    mMaxSize(rhs.mMaxSize),
    mRolloverCount(rhs.mRolloverCount),
    mFile(std::move(rhs.mFile))
    { }
    ~FileOutput ()
    {
        mFile.close();
    }
    std::unique_ptr<Output> clone () const override
    {
        return std::unique_ptr<Output>(
            new FileOutput(*this));
    }
    void sendLine (std::string const & line) override
    {
        if (not mFile.is_open())
        {
            mFile.open("application.log", std::ios::app);
        }
        mFile << line << std::endl;
        mFile.flush();
    }
protected:
    std::filesystem::path mOutputDir;
    std::string mFileNamePattern;
    std::size_t mMaxSize;
    unsigned int mRolloverCount;
    std::fstream mFile;
};

FileOutput 类遵循上一节中确定的用法,如下所示:

    MereMemo::FileOutput appFile("logs");
    appFile.namePattern() = "application-{}.log";
    appFile.maxSize() = 10'000'000;
    appFile.rolloverCount() = 5;
    MereMemo::addLogOutput(appFile);

我们在构造函数中给FileOutput类一个目录,日志文件将保存在那里。该类还支持名称模式、最大日志文件大小和滚动计数。所有数据成员都需要在构造函数中初始化,我们有三个构造函数。

第一个构造函数是一个普通构造函数,它接受目录并为其他数据成员提供默认值。

第二个构造函数是复制构造函数,它根据FileOutput的另一个实例中的值初始化数据成员。只有mFile数据成员保留在默认状态,因为我们没有复制fstream

第三个构造函数是移动复制构造函数,它看起来几乎与复制构造函数相同。唯一的区别是我们现在将fstream移动到正在构建的FileOutput类中。

析构函数将关闭输出文件。这实际上是对之前所做工作的重大改进。我们过去每次记录日志消息时都会打开和关闭输出文件。现在我们将打开日志文件并保持打开状态,直到我们稍后需要关闭它。析构函数确保如果日志文件尚未关闭,则将其关闭。

接下来是clone方法,它调用复制构造函数来创建一个新的实例,并将其作为基类的唯一指针返回。

sendLine方法是最后一个方法,在将行发送到文件之前,它需要检查输出文件是否已经打开。我们将在每行发送到输出文件后添加结束换行符。我们还每行刷新日志文件,这有助于确保在应用程序突然崩溃的情况下,日志文件包含所有写入的内容。

FileOutput类中,我们需要做的最后一件事是定义数据成员。我们不会完全实现所有数据成员。例如,你可以看到我们仍然在打开一个名为application.log的文件,而不是遵循命名模式。我们已经有了基本想法,跳过数据成员将使我们能够测试这部分,以确保我们没有破坏任何东西。我们需要在main函数中注释掉配置,所以现在看起来是这样的:

    MereMemo::FileOutput appFile("logs");
    //appFile.namePattern() = "application-{}.log";
    //appFile.maxSize() = 10'000'000;
    //appFile.rolloverCount() = 5;
    MereMemo::addLogOutput(appFile);

一旦我们以基本方式使多个输出工作,我们就可以随时返回配置方法和目录。这遵循了 TDD 实践,即每一步尽可能少做。从某种意义上说,我们正在为最终的FileOutput类创建一个模拟。

我差点忘了提,因为我们使用了filesystem功能,例如path,所以我们需要在Log.h的顶部包含filesystem,如下所示:

#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <memory>
#include <ostream>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

当我们开始将日志文件滚动到新文件而不是每次都打开相同的文件时,我们将更多地使用filesystem

接下来是StreamOutput类,它可以直接放在FileOutput类后面Log.h中,如下所示:

class StreamOutput : public Output
{
public:
    StreamOutput (std::ostream & stream)
    : mStream(stream)
    { }
    StreamOutput (StreamOutput const & rhs)
    : mStream(rhs.mStream)
    { }
    std::unique_ptr<Output> clone () const override
    {
        return std::unique_ptr<Output>(
            new StreamOutput(*this));
    }
    void sendLine (std::string const & line) override
    {
        mStream << line << std::endl;
    }
protected:
    std::ostream & mStream;
};

StreamOutput类比FileOutput类简单,因为它具有更少的数据成员。我们只需要跟踪在main中的构造函数中传入的 ostream 引用。我们也不需要担心特定的移动复制构造函数,因为我们可以轻松地复制 ostream 引用。StreamOutput类已经在main中添加,如下所示:

    MereMemo::StreamOutput consoleStream(std::cout);
    MereMemo::addLogOutput(consoleStream);

StreamOutput类将持有main传递给它的std::cout的引用。

现在我们正在处理输出接口,我们不再需要在LogStream类中管理文件。构造函数可以简化,不再需要担心 fstream 数据成员,如下所示:

    LogStream ()
    : mProceed(true)
    { }
    LogStream (LogStream const & other) = delete;
    LogStream (LogStream && other)
    : std::stringstream(std::move(other)),
    mProceed(other.mProceed)
    { }

LogStream类的析构函数是所有工作的发生地。它不再需要直接将消息发送到由类管理的文件。析构函数现在获取所有输出,并使用通用接口将消息发送给每个输出,如下所示:

    ~LogStream ()
    {
        if (not mProceed)
        {
            return;
        }

        auto & outputs = getOutputs();
        for (auto const & output: outputs)
        {
            output->sendLine(this->str());
        }
    }

记住,LogStream类从std::stringstream继承,并持有要记录的消息。如果我们继续进行,我们可以通过调用str方法来获取完整格式的消息。

LogStream类的末尾不再需要mFile数据成员,只需要mProceed标志,如下所示:

private:
    bool mProceed;
};

由于我们移除了LogStream构造函数的文件名和打开模式参数,我们可以简化log函数中LogStream类的创建方式,如下所示:

inline LogStream log (std::vector<Tag const *> tags = {})
{
    auto const now = std::chrono::system_clock::now();
    std::time_t const tmNow =          std::chrono::system_clock::to_time_t(now);
    auto const ms = duration_cast<std::chrono::milliseconds>(
        now.time_since_epoch()) % 1000;
    LogStream ls;
    ls << std::put_time(std::gmtime(&tmNow),        "%Y-%m-%dT%H:%M:%S.")
        << std::setw(3) << std::setfill('0')         << std::to_string(ms.count());

我们现在可以不带任何参数构造ls实例,它将使用所有已添加的输出。

让我们通过构建和运行项目来检查测试应用程序。控制台输出的内容如下:

Running 1 test suites
--------------- Suite: Single Tests
------- Test: Message can be tagged in log
2022-07-24T22:32:13.116 color="green" log_level="error" simple 7809
Passed
------- Test: log needs no namespace when used with LogLevel
2022-07-24T22:32:13.118 color="green" log_level="error" no namespace
Passed
------- Test: Default tags set in main appear in log
2022-07-24T22:32:13.118 color="green" log_level="info" default tag 9055
Passed
------- Test: Multiple tags can be used in log
2022-07-24T22:32:13.118 color="red" log_level="debug" size="large" multi tags 7933
Passed
------- Test: Tags can be streamed to log
2022-07-24T22:32:13.118 color="green" log_level="info" count=1 1 type 3247
2022-07-24T22:32:13.118 color="green" log_level="info" id=123456789012345 2 type 6480
2022-07-24T22:32:13.118 color="green" log_level="info" scale=1.500000 3 type 6881
2022-07-24T22:32:13.119 color="green" log_level="info" cache_hit=false 4 type 778
Passed
------- Test: Tags can be used to filter messages
2022-07-24T22:32:13.119 color="green" log_level="info" filter 1521
Passed
------- Test: Overridden default tag not used to filter messages
Passed
------- Test: Inverted tag can be used to filter messages
Passed
------- Test: Tag values can be used to filter messages
2022-07-24T22:32:13.119 color="green" count=101 log_level="info" values 8461
Passed
------- Test: Simple message can be logged
2022-07-24T22:32:13.120 color="green" log_level="info" simple 9466 with more text.
Passed
------- Test: Complicated message can be logged
2022-07-24T22:32:13.120 color="green" log_level="info" complicated 9198 double=3.14 quoted="in quotes"
Passed
-----------------------------------
Tests passed: 11
Tests failed: 0

你可以看到日志消息确实被发送到了控制台窗口。日志消息包含在控制台结果中。那么日志文件呢?它看起来如下所示:

2022-07-24T22:32:13.116 color="green" log_level="error" simple 7809
2022-07-24T22:32:13.118 color="green" log_level="error" no namespace
2022-07-24T22:32:13.118 color="green" log_level="info" default tag 9055
2022-07-24T22:32:13.118 color="red" log_level="debug" size="large" multi tags 7933
2022-07-24T22:32:13.118 color="green" log_level="info" count=1 1 type 3247
2022-07-24T22:32:13.118 color="green" log_level="info" id=123456789012345 2 type 6480
2022-07-24T22:32:13.118 color="green" log_level="info" scale=1.500000 3 type 6881
2022-07-24T22:32:13.119 color="green" log_level="info" cache_hit=false 4 type 778
2022-07-24T22:32:13.119 color="green" log_level="info" filter 1521
2022-07-24T22:32:13.119 color="green" count=101 log_level="info" values 8461
2022-07-24T22:32:13.120 color="green" log_level="info" simple 9466 with more text.
2022-07-24T22:32:13.120 color="green" log_level="info" complicated 9198 double=3.14 quoted="in quotes"

日志文件只包含日志消息,这些日志消息与发送到控制台窗口的日志消息相同。这表明我们有多处输出!没有很好的方法来验证日志消息是否被发送到控制台窗口,例如,我们可以打开日志文件并搜索特定的行。

但我们可以使用StreamOutput类添加另一个输出,该类使用std::fstream而不是std::cout。我们可以这样做,因为 fstream 实现了 ostream,这正是StreamOutput类所需要的。这也是依赖注入,因为StreamOutput类依赖于一个 ostream,我们可以给它任何我们想要的 ostream,如下所示:

#include <fstream>
#include <iostream>
int main ()
{
    MereMemo::FileOutput appFile("logs");
    //appFile.namePattern() = "application-{}.log";
    //appFile.maxSize() = 10'000'000;
    //appFile.rolloverCount() = 5;
    MereMemo::addLogOutput(appFile);
    MereMemo::StreamOutput consoleStream(std::cout);
    MereMemo::addLogOutput(consoleStream);
    std::fstream streamedFile("stream.log", std::ios::app);
    MereMemo::StreamOutput fileStream(streamedFile);
    MereMemo::addLogOutput(fileStream);
    MereMemo::addDefaultTag(info);
    MereMemo::addDefaultTag(green);
    return MereTDD::runTests(std::cout);
}

我们不会进行这个更改。这只是为了演示目的。但它表明你可以打开一个文件并将该文件传递给StreamOutput类以代替控制台输出。如果你真的做出这个更改,那么你会看到stream.logapplication.log文件是相同的。

为什么你想考虑使用StreamOutput就像使用FileOutput一样?而且如果StreamOutput也可以写入文件,我们为什么还需要FileOutput

首先,FileOutput是针对文件专门化的。它最终将知道如何检查当前文件大小,以确保它不会变得太大,并在当前日志文件接近最大大小时滚动到新的日志文件。需要文件管理,而StreamOutput甚至不会意识到这一点。

虽然StreamOutput类更简单,因为它根本不需要担心文件。你可能想使用StreamOutput将内容写入文件,以防FileOutput类创建得太慢。当然,我们创建了一个没有所有文件管理功能的简化FileOutput,但另一个团队可能并不愿意给你一个部分实现。你可能发现,在等待完整实现的同时使用模拟解决方案会更好。

能够交换一种实现方式为另一种实现方式是,通过适当管理的依赖关系获得的一大优势。

事实上,这本书将保留当前FileOutput的实现方式,因为它现在是这样,因为完成实现将使我们进入与学习 TDD 关系不大的主题。

摘要

我们不仅为日志库添加了一个让它能够将日志消息发送到多个目的地的出色新功能,而且还使用接口添加了这种能力。该接口有助于记录和隔离将文本行发送到目的地的概念。这有助于揭示日志库的一个依赖关系。日志库依赖于将文本发送到某处的功能。

目的地可以是日志文件或控制台,或者任何其他地方。在我们确定这个依赖关系之前,日志库在很多地方都做出了假设,认为它只与日志文件一起工作。我们能够简化设计,同时创建一个更灵活的设计。

我们还能够在没有完整的文件记录组件的情况下使文件记录功能正常工作。我们创建了一个文件记录组件的模拟,省略了完整实现所需的所有额外文件管理任务。虽然这些附加功能很有用,但目前并不需要,这个模拟将使我们能够在没有它们的情况下继续前进。

下一章将回到单元测试库,并展示如何将确认提升到一个可扩展且更容易理解的新风格。

第三部分:扩展 TDD 库以支持日志库不断增长的需求

本书分为三部分。在这第三部分和最后一部分,我们将增强单元测试确认,以使用一种称为 Hamcrest 确认的新现代风格。你还将学习如何测试服务和如何使用多线程进行测试。这一部分将把你迄今为止所学的一切结合起来,并为你使用 TDD 在自己的项目中做好准备。

本部分涵盖了以下章节:

  • 第十二章创建更好的测试断言

  • 第十三章如何测试浮点数和自定义值

  • 第十四章, 如何测试服务

  • 第十五章, 多线程测试

第十二章:创建更好的测试确认

本章介绍了第三部分,其中我们将 TDD 库扩展以支持日志库不断增长的需求。本书的第一部分测试 MVP,开发了一个基本的单元测试库,第二部分日志库,开始使用单元测试库构建日志库。现在,我们正在遵循 TDD,它鼓励在基本测试运行良好后进行增强。

嗯,我们成功使基本的单元测试库运行起来,并通过构建日志库证明了其价值。从某种意义上说,日志库就像是单元测试库的系统测试。现在,是时候增强单元测试库了。

本章向单元测试库添加了一种全新的确认类型。首先,我们将查看现有的确认,以了解它们如何改进以及新解决方案将是什么样子。

新的确认将更加直观、灵活和可扩展。并且请记住,不仅要关注本章开发的代码,还要关注过程。这是因为我们将使用 TDD 来编写一些测试,从简单的解决方案开始,然后增强测试以创建更好的解决方案。

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

  • 当前确认存在的问题

  • 如何简化字符串确认

  • 增强单元测试库以支持 Hamcrest 风格的确认

  • 添加更多 Hamcrest 匹配器类型

技术要求

本章中所有代码都使用标准 C++,它基于任何现代 C++ 20 或更高版本的编译器和标准库。代码基于并继续增强本书第一部分测试 MVP中的测试库。

你可以在以下 GitHub 仓库中找到本章所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

当前确认存在的问题

在我们开始进行更改之前,我们应该对为什么要这样做有一个想法。TDD 完全是关于客户体验的。我们如何设计出易于使用且直观的东西?让我们先看看几个现有的测试:

TEST("Test string and string literal confirms")
{
    std::string result = "abc";
    CONFIRM("abc", result);
}
TEST("Test float confirms")
{
    float f1 = 0.1f;
    float f2 = 0.2f;
    float sum = f1 + f2;
    float expected = 0.3f;
    CONFIRM(expected, sum);
}

这些测试已经很好地服务了,而且很简单,对吧?我们在这里关注的不光是测试本身,而是确认。这种确认风格被称为经典风格

我们该如何大声说出第一个确认?可能如下所示:“确认 abc 的预期值与结果值匹配。”

这还不错,但有点尴尬。这不是人们通常说话的方式。不查看任何代码,表达相同内容的一种更自然的方式是:“确认结果等于 abc。”

初看之下,我们可能只需要颠倒参数的顺序,将实际值放在预期值之前。但这里缺少了一个部分。我们如何知道一个确认是在检查相等性呢?我们知道,因为现有的confirm函数只会检查这一项。这也意味着CONFIRM宏也只知道如何检查相等性。

对于布尔值,我们有一个更好的解决方案,因为我们创建了特殊的CONFIRM_TRUECONFIRM_FALSE宏,它们易于使用和理解。而且因为布尔版本只接受一个参数,所以不存在预期值与实际值顺序的问题。

有一个更好的解决方案,与更自然的确认方式相一致。这个更好的解决方案使用了一种称为匹配器的东西,被称为Hamcrest 风格。名字“Hamcrest”只是将“matchers”这个词的字母顺序重新排列。以下是一个用 Hamcrest 风格编写的测试示例:

TEST("Test can use hamcrest style confirm")
{
    int ten = 10;
    CONFIRM_THAT(ten, Equals(10));
}

我们在这本书中并不是真正设计 Hamcrest 风格的。这种风格已经存在,并且在其他测试库中很常见。本书中测试库将预期值放在实际值之前,遵循经典风格的常见做法。

想象一下,如果你要重新发明一个更好的灯开关。我曾在一些尝试过的建筑中待过。灯开关在某些方面可能实际上更好。但如果它不符合正常的预期,那么人们会感到困惑。

这本书开始时我们使用的经典确认也是如此。我本可以将确认设计成将实际值放在前面,也许那样会更好。但对于任何对现有测试库稍有了解的人来说,这将是出乎意料的。

这在创建 TDD 设计时提出了一个值得考虑的要点。有时,当客户期望的是次优解决方案时,次优解决方案反而更好。记住,我们设计的任何东西都应该易于使用且直观。目标不是创造终极和最现代的设计,而是创造用户会满意的东西。

这就是为什么 Hamcrest 匹配器可以工作。设计并不是仅仅交换预期值和实际值的顺序,因为仅仅交换顺序本身只会让用户感到困惑。

Hamcrest 工作得很好,因为还增加了一些其他东西:匹配器。注意确认中的Equals(10)部分。Equals是一个匹配器,它清楚地说明了确认正在做什么。匹配器与更直观的顺序结合,为解决方案提供了足够的优势,以克服人们转向新做事方式的自然抵触。Hamcrest 风格不仅仅是一个更好的灯开关。Hamcrest 足够不同,提供了足够的价值,避免了稍微好一些但不同的解决方案的困惑。

此外,请注意,宏的名称已从 CONFIRM 更改为 CONFIRM_THAT。名称更改是避免混淆的另一种方式,并允许用户继续使用较老的经典风格或选择较新的 Hamcrest 风格。

现在我们有一个地方可以指定像 Equals 这样的东西,我们也可以使用不同的匹配器,比如 GreaterThanBeginsWith。想象一下,如果你想要确认某些文本以某些预期的字符开始,你会如何编写这样的测试?你必须在确认之外检查开始文本,然后确认检查的结果。使用 Hamcrest 风格和适当的匹配器,你可以用单行确认来确认文本。而且你得到了一个更易于阅读的确认,这清楚地表明了正在确认的内容。

如果您找不到符合您需求的匹配器,您总是可以编写自己的来做到您需要的精确程度。因此,Hamcrest 是可扩展的。

在深入探讨新的 Hamcrest 设计之前,下一节将稍微偏离一下,解释对现有的经典 confirm 模板函数的改进。这个改进将在 Hamcrest 设计中使用,因此首先理解这个改进将有助于我们稍后到达 Hamcrest 代码解释时。

简化字符串确认

当我编写本章的代码时,我遇到了一个确认字符串数据类型的问题,这让我想起了我们在第五章“添加更多确认类型”中添加对字符串确认支持的情况。第五章的动机因素是为了让代码能够编译,因为我们不能将 std::string 传递给 std::to_string 函数。我将在下面简要地再次解释这个问题。

我不确定确切的原因,但我想 C++ 标准库的设计者认为没有必要提供接受 std::stringstd::to_string 重载,因为没有必要的转换。字符串已经是字符串了!为什么要把某物转换成它已经是的东西呢?

可能这个决定是有意为之,也可能是一个疏忽。但确实,如果有一个将字符串转换为字符串的转换,对于需要将它们的泛型类型转换为字符串的模板函数来说,这会大有帮助。因为没有这个重载,我们不得不采取额外的步骤来避免编译错误。我们需要的是一个可以将任何类型转换为字符串的 to_string 函数,即使类型已经是字符串。如果我们总是能够将类型转换为字符串,那么模板就不需要为字符串进行特殊化。

第五章中,我们介绍了这个模板:

template <typename T>
void confirm (
    T const & expected,
    T const & actual,
    int line)
{
    if (actual != expected)
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

confirm函数接受两个模板参数,称为expectedactual,它们用于比较相等性。如果不相等,则函数将这两个参数传递给抛出的异常。参数需要根据需要通过ActualConfirmException构造函数转换为字符串。

这是我们遇到问题的所在。如果使用字符串调用confirm模板函数,那么它将无法编译,因为字符串不能通过调用std::to_string转换为字符串。

在第五章中我们采取的解决方案是使用直接接受字符串的非模板版本的confirm函数进行重载。我们实际上创建了两个重载,一个用于字符串,一个用于字符串视图。这解决了问题,但留下了以下两个额外的重载:

inline void confirm (
    std::string_view expected,
    std::string_view actual,
    int line)
{
    if (actual != expected)
    {
        throw ActualConfirmException(
            expected,
            actual,
            line);
    }
}
inline void confirm (
    std::string const & expected,
    std::string const & actual,
    int line)
{
    confirm(
        std::string_view(expected),
        std::string_view(actual),
        line);
}

当使用字符串调用confirm时,这些重载会代替模板使用。接受std::string类型的版本会调用接受std::string_view类型的版本,该版本直接使用expectedactual参数,而不尝试调用std::to_string

在当时,这并不是一个糟糕的解决方案,因为我们已经有了confirm函数的额外重载,用于 bool 类型和各种浮点类型。为字符串添加两个额外的重载是可以接受的。稍后,你将看到一个小小的改动将使我们能够移除这两个字符串重载。

现在我们回到本章将要讨论的新 Hamcrest 设计中的字符串数据类型转换问题。即使对于 bool 或浮点类型,我们也不再需要额外的confirm重载。在我开发新解决方案的过程中,我回到了第五章中较早的解决方案,并决定重构现有的经典确认,以便两种解决方案相似。

我们将在本章后面讨论新的设计。但为了避免打断这个解释,我决定现在先绕道解释如何移除经典confirm函数的字符串和字符串视图重载的需求。现在通过这个解释应该也会更容易理解新的 Hamcrest 设计,因为你已经熟悉了这个解决方案的这部分。

此外,我想补充一点,TDD 有助于这种重构。因为我们已经有了经典确认的现有测试,我们可以移除confirm的字符串重载,并确保所有测试继续通过。我之前在项目中工作过,只有新代码会使用更好的解决方案,我们必须保持现有代码不变,以避免引入错误。这样做只是让代码更难维护,因为现在同一个项目中会有两种不同的解决方案。良好的测试有助于给你所需的信心,以便更改现有代码。

好吧,问题的核心是 C++ 标准库不包括与字符串一起工作的 to_string 重载。虽然添加我们自己的 to_string 版本到 std 命名空间可能很有吸引力,但这是不允许的。它可能工作,我确信很多人已经这样做了。但是,将任何函数添加到 std 命名空间是技术上的未定义行为。有一些非常具体的情况,我们被允许将某些内容添加到 std 命名空间,不幸的是,这并不是允许的例外之一。

我们将需要我们自己的 to_string 版本。我们只是不能将其放入 std 命名空间。这是一个问题,因为当我们调用 to_string 时,我们目前通过调用 std::to_string 来指定命名空间。我们需要做的是简单地调用 to_string 而不带任何命名空间,让编译器在 std 命名空间中查找与数值类型一起工作的 to_string 版本,或者在我们的命名空间中查找我们新的与字符串一起工作的版本。新的 to_string 函数和修改后的 confirm 模板函数看起来像这样:

inline std::string to_string (std::string const & str)
{
    return str;
}
template <typename ExpectedT, typename ActualT>
void confirm (
    ExpectedT const & expected,
    ActualT const & actual,
    int line)
{
    using std::to_string;
    using MereTDD::to_string;
    if (actual != expected)
    {
        throw ActualConfirmException(
            to_string(expected),
            to_string(actual),
            line);
    }
}

我们可以移除接受字符串视图和字符串的 confirm 的两个重载。现在,confirm 模板函数将适用于字符串。

新的接受 std::stringto_string 函数只需要返回相同的字符串。我们实际上不需要另一个与字符串视图一起工作的 to_string 函数。

confirm 模板函数稍微复杂一些,因为它现在需要两种类型,ExpectedTActualT。这两种类型是用于那些我们需要比较字符串字面量和字符串的情况,例如以下测试中所示:

TEST("Test string and string literal confirms")
{
    std::string result = "abc";
    CONFIRM("abc", result);
}

这个测试之所以在只有一个 confirm 模板参数时能够编译,是因为它没有调用模板。编译器将 "abc" 字符串字面量转换为字符串,并调用接受两个字符串的重载的 confirm。或者,它可能将字符串字面量和字符串都转换为字符串视图,并调用接受两个字符串视图的重载的 confirm。无论如何,因为我们有单独的 confirm 重载,编译器能够使其工作。

现在我们已经移除了处理字符串的 confirm 重载,我们只剩下模板,我们需要让它接受不同的类型以便编译。我知道,我们仍然有处理布尔型和浮点型的重载。我只是在谈论我们可以移除的字符串重载。

在新的模板中,你可以看到我们没有指定任何命名空间就调用了to_string。由于模板函数内部有两个 using 语句,编译器能够找到所需的to_string版本。第一个 using 语句告诉编译器应该考虑std命名空间中所有的to_string重载。第二个 using 语句告诉编译器还应考虑在MereTDD命名空间中找到的任何to_string函数。

confirm函数使用数值类型调用时,编译器现在能够找到一个与数值类型兼容的to_string版本。当需要时,编译器也可以找到我们新的与字符串兼容的to_string函数。我们不再需要限制编译器只查找std命名空间。

现在,我们可以回到新的 Hamcrest 风格设计,我们将在下一节中完成。Hamcrest 设计最终将使用与这里刚刚描述的类似解决方案。

增强测试库以支持 Hamcrest 匹配器

一旦基本实现工作正常并通过测试,TDD(测试驱动开发)会引导我们通过创建更多测试并让新测试通过来增强设计。这正是本章的全部内容。我们正在增强经典风格的确认方式以支持 Hamcrest 风格。

让我们从创建一个新文件开始,这个文件叫做Hamcrest.cpp,位于tests文件夹中。现在,整个项目结构应该看起来像这样:

MereTDD project root folder
    Test.h
    tests folder
        main.cpp
        Confirm.cpp
        Creation.cpp
        Hamcrest.cpp
        Setup.cpp

如果你一直跟随这本书中的所有代码,记得我们正在回到我们在第七章中最后工作的MereTDD项目,测试设置和清理。这不是MereMemo日志项目。

我们需要支持的 Hamcrest 风格测试放在Hamcrest.cpp文件中,这样新的文件看起来就像这样:

#include "../Test.h"
TEST("Test can use hamcrest style confirm")
{
    int ten = 10;
    CONFIRM_THAT(ten, Equals(10));
}

我们不妨从新的CONFIRM_THAT宏开始,它位于Test.h文件的末尾,紧随其他CONFIRM宏之后,如下所示:

#define CONFIRM_FALSE( actual ) \
    MereTDD::confirm(false, actual, __LINE__)
#define CONFIRM_TRUE( actual ) \
    MereTDD::confirm(true, actual, __LINE__)
#define CONFIRM( expected, actual ) \
    MereTDD::confirm(expected, actual, __LINE__)
#define CONFIRM_THAT( actual, matcher ) \
    MereTDD::confirm_that(actual, matcher, __LINE__)

CONFIRM_THAT宏与CONFIRM宏类似,除了actual参数放在第一位,而不是expected参数,我们有一个名为matcher的参数。我们还将调用一个新的函数confirm_that。这个新函数有助于使保持经典风格的confirm重载与 Hamcrest 风格的confirm_that函数分开变得更加简单。

我们不需要像confirm那样所有的重载。confirm_that函数可以用一个单独的模板函数实现。将这个新的模板放在Test.h文件中,紧随经典的confirm模板函数之后。这两个模板函数应该看起来像这样:

template <typename ExpectedT, typename ActualT>
void confirm (
    ExpectedT const & expected,
    ActualT const & actual,
    int line)
{
    using std::to_string;
    using MereTDD::to_string;
    if (actual != expected)
    {
        throw ActualConfirmException(
            to_string(expected),
            to_string(actual),
            line);
    }
}
template <typename ActualT, typename MatcherT>
inline void confirm_that (
    ActualT const & actual,
    MatcherT const & matcher,
    int line)
{
    using std::to_string;
    using MereTDD::to_string;
    if (not matcher.pass(actual))
    {
        throw ActualConfirmException(
            to_string(matcher),
            to_string(actual),
            line);
    }
}

我们只添加了confirm_that函数。我决定展示两个函数,这样你可以更容易地看到它们之间的差异。注意,现在,ActualT类型被放在了第一位。顺序实际上并不重要,但我喜欢将模板参数按照合理的顺序排列。我们不再有ExpectedT类型;相反,我们有一个MatcherT类型。

新的模板函数的名称也不同,因此由于相似的模板参数而导致的歧义不存在。新的模板函数被称为 confirm_that

当经典 confirm 函数直接比较 actual 参数和 expected 参数时,新的 confirm_that 函数会调用 matcher 上的 pass 方法来执行检查。我们并不真正知道 matcherpass 方法中会做什么,因为这取决于 matcher。而且,由于任何类型之间的比较变化都被封装在 matcher 中,我们不需要像经典 confirm 函数那样重载 confirm_that 函数。我们仍然需要特殊的代码,但差异将由本设计中的 matcher 处理。

正是在这里我意识到,需要为将 matcheractual 参数转换为字符串找到一个不同的解决方案。仅仅为了避免当 ActualT 的类型为字符串时调用 to_string,而重写 confirm_that 看起来毫无意义。因此,我停止调用 std::to_string(actual),而是开始调用 to_string(actual)。为了让编译器找到必要的 to_string 函数,需要使用 using 语句。这正是前一小节中描述的简化字符串比较的解释。

现在我们有了 confirm_that 模板,我们可以专注于 matcher。我们需要能够调用一个 pass 方法并将 matcher 转换为字符串。让我们创建一个所有匹配器都可以继承的基类,这样它们都将有一个共同的接口。将这个基类和 to_string 函数放在 Test.h 中的 confirm_that 函数之后,如下所示:

class Matcher
{
public:
    virtual ~Matcher () = default;
    Matcher (Matcher const & other) = delete;
    Matcher (Matcher && other) = delete;
    virtual std::string to_string () const = 0;
    Matcher & operator = (Matcher const & rhs) = delete;
    Matcher & operator = (Matcher && rhs) = delete;
protected:
    Matcher () = default;
};
inline std::string to_string (Matcher const & matcher)
{
    return matcher.to_string();
}

to_string 函数将使我们通过调用 Matcher 基类中的虚拟 to_string 方法,将匹配器转换为字符串。注意在 Matcher 类中并没有 pass 方法。

Matcher 类本身是一个基类,不需要被复制或赋值。Matcher 类定义的唯一公共接口是一个 to_string 方法,所有匹配器都将实现这个方法,将自身转换为可以发送到测试运行摘要报告的字符串。

pass 方法怎么了?嗯,pass 方法需要接受实际类型,该类型将用于确定实际值是否与预期值匹配。预期值本身将保存在派生匹配器类中。实际值将传递给 pass 方法。

实际值和预期值接受的类型将完全由派生匹配器类控制。因为类型可以从一个匹配器的使用改变到另一个使用,所以我们不能在Matcher基类中定义一个pass方法。这是可以接受的,因为confirm_that模板不与Matcher基类一起工作。confirm_that模板将了解实际的匹配器派生类,并且可以直接作为非虚方法调用pass方法。

to_string方法不同,因为我们想在接受任何Matcher引用的to_string辅助函数内部调用虚拟的Matcher::to_string方法。

因此,当将匹配器转换为字符串时,我们对待所有匹配器都是一样的,并通过虚拟的to_string方法进行。而在调用pass时,我们直接与真实的匹配器类一起工作,并直接调用pass

让我们看看一个真实的匹配器类将是什么样子。我们正在实现的测试使用了一个名为Equals的匹配器。我们可以在Matcher类和to_string函数之后立即创建派生的Equals类,如下所示:

template <typename T>
class Equals : public Matcher
{
public:
    Equals (T const & expected)
    : mExpected(expected)
    { }
    bool pass (T const & actual) const
    {
        return actual == mExpected;
    }
    std::string to_string () const override
    {
        using std::to_string;
        using MereTDD::to_string;
        return to_string(mExpected);
    }
private:
    T mExpected;
};

Equals类是另一个模板,因为它需要持有正确的预期值类型,并且它需要在pass方法中使用相同的类型作为actual参数。

注意,to_string重写方法使用了与我们将要使用相同的解决方案来将mExpected数据成员转换为字符串。我们调用to_string并让编译器在stdMereTDD命名空间中找到适当的匹配项。

我们需要做一个小改动才能让一切正常工作。在我们的 Hamcrest 测试中,我们使用Equals匹配器而不指定任何命名空间。我们可以将其称为MereTDD::Equals。但命名空间指定会分散测试的可读性。让我们在将使用 Hamcrest 匹配器的任何测试文件顶部添加一个using namespace MereTDD语句,这样我们就可以直接引用它们,如下所示:

#include "../Test.h"
using namespace MereTDD;
TEST("Test can use hamcrest style confirm")
{
    int ten = 10;
    CONFIRM_THAT(ten, Equals(10));
}

这是我们支持第一个 Hamcrest 匹配器单元测试所需的一切——构建和运行测试以显示所有测试都通过。那么预期的失败会怎样呢?首先,让我们创建一个像这样的新测试:

TEST("Test hamcrest style confirm failure")
{
    int ten = 10;
    CONFIRM_THAT(ten, Equals(9));
}

这个测试被设计为失败的,因为10不会等于9。我们需要构建和运行一次,只是为了从总结报告中获取失败信息。然后,我们可以添加一个调用setExpectedFailureReason的语句,并带有精确格式的失败信息。记住,失败信息需要完全匹配,包括所有的空格和标点符号。我知道这可能会很繁琐,但除非你正在测试自己的自定义匹配器以确保自定义匹配器能够格式化正确的错误信息,否则你不需要担心这个测试。

在获取确切的错误信息后,我们可以修改测试,将其转换为预期的失败,如下所示:

TEST("Test hamcrest style confirm failure")
{
    std::string reason = "    Expected: 9\n";
    reason += "    Actual  : 10";
    setExpectedFailureReason(reason);
    int ten = 10;
    CONFIRM_THAT(ten, Equals(9));
}

再次构建和运行会显示两个 Hamcrest 测试结果,如下所示:

------- Test: Test can use hamcrest style confirm
Passed
------- Test: Test hamcrest style confirm failure
Expected failure
    Expected: 9
    Actual  : 10

这是一个好的开始。我们还没有开始讨论如何设计自定义匹配器。在我们开始自定义匹配器之前,其他基本类型怎么样?我们只有几个比较整数值的 Hamcrest 测试。下一节将探讨其他基本类型并添加更多测试。

添加更多 Hamcrest 类型

你现在应该熟悉使用 TDD 的模式。我们添加一点东西,使其工作,然后添加更多。我们有能力使用 Hamcrest 的 Equals 匹配器确认整数值。现在是时候添加更多类型了。其中一些类型可能不需要额外的工作就能工作,这要归功于模板 confirm_that 函数。其他类型可能需要更改。我们将通过编写一些测试来找出需要做什么。

第一个测试确保其他整数类型按预期工作。将此测试添加到 Hamcrest.cpp

TEST("Test other hamcrest style integer confirms")
{
    char c1 = 'A';
    char c2 = 'A';
    CONFIRM_THAT(c1, Equals(c2));
    CONFIRM_THAT(c1, Equals('A'));
    short s1 = 10;
    short s2 = 10;
    CONFIRM_THAT(s1, Equals(s2));
    CONFIRM_THAT(s1, Equals(10));
    unsigned int ui1 = 3'000'000'000;
    unsigned int ui2 = 3'000'000'000;
    CONFIRM_THAT(ui1, Equals(ui2));
    CONFIRM_THAT(ui1, Equals(3'000'000'000));
    long long ll1 = 5'000'000'000'000LL;
    long long ll2 = 5'000'000'000'000LL;
    CONFIRM_THAT(ll1, Equals(ll2));
    CONFIRM_THAT(ll1, Equals(5'000'000'000'000LL));
}

首先,测试声明了一些字符,并以几种不同的方式使用 Equals 匹配器。第一种是测试与另一个字符的相等性。第二种使用字符字面量值 'A' 进行比较。

第二组确认基于短整数。我们使用 Equals 匹配器与另一个短整数以及整数字面量值 10 进行比较。

第三组确认基于无符号整数,并且再次尝试使用 Equals 匹配器与相同类型的另一个变量以及字面量整数进行比较。

第四组确认确保了长长类型得到支持。

我们没有创建旨在模拟其他正在测试的软件的辅助函数。你已经知道如何根据日志库中的测试在真实项目中使用确认。这就是为什么这个测试使事情简单,并且只专注于确保 CONFIRM_THAT 宏(它调用 confirm_that 模板函数)能够正常工作。

构建和运行这些测试表明,所有测试都通过,无需任何更改或增强。

关于布尔类型呢?这里有一个测试,它进入 Hamcrest.cpp 以测试布尔类型:

TEST("Test hamcrest style bool confirms")
{
    bool b1 = true;
    bool b2 = true;
    CONFIRM_THAT(b1, Equals(b2));
    // This works but probably won't be used much.
    CONFIRM_THAT(b1, Equals(true));
    // When checking a bool variable for a known value,
    // the classic style is probably better.
    CONFIRM_TRUE(b1);
}

这个测试表明,Hamcrest 风格也适用于布尔类型。当比较一个布尔变量与另一个布尔变量时,Hamcrest 风格比经典风格更好。然而,当比较布尔变量与预期的真或假字面量时,使用经典风格实际上更易于阅读,因为我们已经简化了 CONFIRM_TRUECONFIRM_FALSE 宏。

现在,让我们通过这个测试进入 Hamcrest.cpp 来处理字符串。请注意,这个测试最初将无法编译,这是可以接受的。测试看起来像这样:

TEST("Test hamcrest style string confirms")
{
    std::string s1 = "abc";
    std::string s2 = "abc";
    CONFIRM_THAT(s1, Equals(s2));     // string vs. string
    CONFIRM_THAT(s1, Equals("abc"));  // string vs. literal
    CONFIRM_THAT("abc", Equals(s1));  // literal vs. string
}

这个测试中有几个确认,这是可以接受的,因为它们都是相关的。注释有助于阐明每个确认正在测试的内容。

我们在新的测试中总是寻找两件事。第一是测试是否能够编译。第二是测试是否通过。目前,测试将无法编译并出现类似于以下错误的错误:

MereTDD/tests/../Test.h: In instantiation of 'MereTDD::Equals<T>::Equals(const T&) [with T = char [4]]':
MereTDD/tests/Hamcrest.cpp:63:5:   required from here
MereTDD/tests/../Test.h:209:7: error: array used as initializer
  209 |     : mExpected(expected)
      |       ^~~~~~~~~~~~~~~~~~~

你可能会得到不同的行号,所以我将解释错误所指的是什么。失败发生在 Equals 构造函数中,如下所示:

    Equals (T const & expected)
    : mExpected(expected)
    { }

Hamcrest.cpp 的第 63 行是以下这一行:

    CONFIRM_THAT(s1, Equals("abc"));  // string vs. literal

我们正在尝试使用 "abc" 字符串字面量构造一个 Equals 匹配器,但这无法编译。原因是 T 类型是一个需要以不同方式初始化的数组。

我们需要的是一种特殊的 Equals 版本,它可以与字符串字面量一起工作。由于字符串字面量是一个常量字符数组,以下模板特化将有效。将这个新模板放在 Test.h 中,紧接在现有的 Equals 模板之后:

template <typename T, std::size_t N> requires (
    std::is_same<char, std::remove_const_t<T>>::value)
class Equals<T[N]> : public Matcher
{
public:
    Equals (char const (& expected)[N])
    {
        memcpy(mExpected, expected, N);
    }
    bool pass (std::string const & actual) const
    {
        return actual == mExpected;
    }
    std::string to_string () const override
    {
        return std::string(mExpected);
    }
private:
    char mExpected[N];
};

我们需要在 Test.h 中添加一些额外的包含,用于 cstringtype_traits,如下所示:

#include <cstring>
#include <map>
#include <ostream>
#include <string_view>
#include <type_traits>
#include <vector>

模板特化使用了新的 C++20 功能,称为 requires,它帮助我们为模板参数添加约束。requires 关键字实际上是 C++20 中更大增强的一部分,称为 概念。概念是 C++ 的巨大增强,完整的解释超出了本书的范围。我们使用概念和 requires 关键字来简化模板特化,使其仅与字符串一起工作。模板本身接受一个 T 类型,就像之前一样,以及一个新的数值 N,它将是字符串字面量的大小。requires 子句确保 T 是一个字符。我们需要从 T 中移除 const 限定符,因为字符串字面量实际上是常量。

然后 Equals 特化声明它是一个 T[N] 的数组。构造函数接受一个 N 个字符的数组的引用,并且不再尝试直接使用构造函数的 expected 参数初始化 mExpected,而是现在调用 memcpy 将字符从字面量复制到 mExpected 数组中。char const (& expected)[N] 的奇怪语法是 C++ 指定不退化成简单指针的数组作为方法参数的方式。

现在 pass 方法可以接受一个字符串引用作为其 actual 参数类型,因为我们知道我们正在处理字符串。此外,to_string 方法可以直接从 mExpected 字符数组构造并返回 std::string

Equals 模板特化和 pass 方法的有趣之处,也许只是理论上的好处,是我们现在可以确认一个字符串字面量等于另一个字符串字面量。我想不出任何地方会有用,但它确实可以工作,所以我们不妨像这样将其添加到测试中:

TEST("Test hamcrest style string confirms")
{
    std::string s1 = "abc";
    std::string s2 = "abc";
    CONFIRM_THAT(s1, Equals(s2));       // string vs. string
    CONFIRM_THAT(s1, Equals("abc"));    // string vs. literal
    CONFIRM_THAT("abc", Equals(s1));    // literal vs. string
    // Probably not needed, but this works too.
    CONFIRM_THAT("abc", Equals("abc")); // literal vs. Literal
}

字符指针怎么样?它们在模板参数中不如字符数组常见,因为字符数组来源于字符串字面量的工作。字符指针略有不同。我们应该考虑字符指针,因为虽然它们在模板参数中不常见,但字符指针可能比字符数组更常见。以下是一个演示字符指针的测试。请注意,这个测试目前还不能编译。将以下内容添加到 Hamcrest.cpp 中:

TEST("Test hamcrest style string pointer confirms")
{
    char const * sp1 = "abc";
    std::string s1 = "abc";
    char const * sp2 = s1.c_str();    // avoid sp1 and sp2 being same
    CONFIRM_THAT(sp1, Equals(sp2));   // pointer vs. pointer
    CONFIRM_THAT(sp2, Equals("abc")); // pointer vs. literal
    CONFIRM_THAT("abc", Equals(sp2)); // literal vs. pointer
    CONFIRM_THAT(sp1, Equals(s1));    // pointer vs. string
    CONFIRM_THAT(s1, Equals(sp1));    // string vs. pointer
}

我们可以像初始化std::string一样,给字符指针初始化一个字符串字面量。但是,虽然std::string会将文本复制到自己的内存中以便管理,字符指针只是指向字符串字面量的第一个字符。我一直在说我们在处理字符指针。但为了更具体,我们正在处理常量字符指针。代码需要使用const,但我在说话或写作时有时会省略const

新的字符串指针测试确认了需要采取额外步骤以确保sp1sp2指向不同的内存地址。

C++中的字符串字面量被合并,所以重复的字面量值都指向相同的内存地址。即使一个字面量如"abc"在源代码中可能被多次使用,最终的可执行文件中也只会有一个字符串字面量的副本。测试必须通过额外步骤来确保sp1sp2具有不同的指针值,同时保持相同的文本。每当std::string用字符串字面量初始化时,字符串字面量的文本就会被复制到std::string中以便管理。std::string可能会使用动态分配的内存或栈上的局部内存。std::string不会仅仅指向初始化时使用的内存地址。如果我们简单地像sp1一样初始化sp2,那么两个指针都会指向相同的内存地址。但通过将sp2初始化为指向s1中的字符串,那么sp2就指向了与sp1不同的内存地址。尽管sp1sp2指向不同的内存地址,但每个地址上文本字符的值是相同的。

好的,现在你明白了测试在做什么,它编译了吗?不。在尝试在confirm_that模板函数中调用pass方法时,构建失败了。

导致构建失败的测试中的那一行是最后的确认。编译器试图将s1字符串转换为常量字符指针。但这是有误导性的,因为即使我们注释掉最后的确认,构建成功,但测试在运行时仍然会失败,如下所示:

------- Test: Test hamcrest style string pointer confirms
Failed confirm on line 75
    Expected: abc
    Actual  : abc

因为你可能得到不同的行号,我会解释第 75 行是测试中的第一个确认:

    CONFIRM_THAT(sp1, Equals(sp2));   // pointer vs. pointer

看看测试失败的信息。它说"abc"不等于"abc"!这是怎么回事?

因为我们使用的是原始的Equals模板类,它只知道我们正在处理字符指针。当我们调用pass时,被比较的是指针值。而且因为我们采取了额外步骤确保sp1sp2具有不同的指针值,所以测试失败了。即使两个指针所引用的文本相同,测试也会失败。

为了支持指针,我们需要对Equals进行另一个模板特殊化。但我们不能对任何指针类型进行特殊化,就像我们不能对任何数组类型进行特殊化一样。我们确保数组特殊化只适用于 char 数组。因此,我们也应该确保我们的指针特殊化只与 char 指针一起工作。在Test.h中的第二个Equals类之后添加这个特殊化:

template <typename T> requires (
    std::is_same<char, std::remove_const_t<T>>::value)
class Equals<T *> : public Matcher
{
public:
    Equals (char const * expected)
    : mExpected(expected)
    { }
    bool pass (std::string const & actual) const
    {
        return actual == mExpected;
    }
    std::string to_string () const override
    {
        return mExpected;
    }
private:
    std::string mExpected;
};

使用这个Equals类的第三个版本,我们不仅修复了构建错误,所有的确认都通过了!这个模板为T *专门化了Equals,并要求T是一个 char 类型。

构造函数接受一个指向常量字符的指针,并用该指针初始化mExpectedmExpected数据成员是std::string,它知道如何从指针初始化自己。

pass方法也接受std::string,这将允许它与实际的字符串或 char 指针进行比较。此外,to_string方法可以直接返回mExpected,因为它已经是一个字符串。

当我们在第五章“添加更多确认类型”中添加更多经典确认时,我们添加了对浮点类型的特殊支持。我们还需要在 Hamcrest 风格中添加对确认浮点类型的特殊支持。Hamcrest 的浮点特殊化将在下一章中介绍,包括如何编写自定义匹配器。

摘要

我们在本章中使用了 TDD 来添加 Hamcrest 确认,甚至改进了现有的经典确认代码。没有 TDD,真实项目中的现有代码可能不会得到管理层的批准进行更改。

本章向您展示了拥有单元测试的好处,这些测试可以帮助验证在做出更改后代码的质量。我们能够重构现有的经典确认设计,以处理字符串,使其与新设计相匹配,该设计有类似的需求。这使得经典和 Hamcrest 确认可以共享类似的设计,而不是维护两种不同的设计。所有这些更改都是可能的,因为单元测试验证了一切都按预期继续运行。

本章最重要的变化是添加了 Hamcrest 风格的确认,这些确认比在第四章“向项目中添加测试”中开发的经典确认更直观、更灵活。此外,新的 Hamcrest 确认也是可扩展的。

我们遵循 TDD 方法添加了对 Hamcrest 确认的支持,这让我们可以简单地开始。这种简单性是关键的,因为我们很快进入了更高级的模板特殊化,甚至是一个新的 C++20 特性,称为requires,它允许我们指定模板应该如何使用。

TDD 使软件设计过程更加流畅——从项目开始或增强初期简单的想法,到像本章开发这样的增强解决方案。尽管我们已经有工作的 Hamcrest 确认,但我们还没有完成。我们将在下一章继续增强确认,确保我们可以确认浮点值和自定义类型值。

第十三章:如何测试浮点数和自定义值

我们第一次遇到测试浮点值的需求是在第五章中,添加更多确认类型,我们创建了一个简单的解决方案,允许我们在误差范围内比较浮点值。我们需要小的误差范围,因为接近且可能看起来相同的浮点值几乎总是不完全相等。这些小的差异使得验证测试结果变得困难。

本章的主要内容包括:

  • 更精确的浮点数比较

  • 添加浮点数 Hamcrest 匹配器

  • 编写自定义 Hamcrest 匹配器

我们将改进之前开发的简单解决方案,使其成为一种更好的比较浮点数的方法,这种方法更精确,适用于小数和大数。我们将使用更好的比较方法来处理早期的经典风格确认和新 Hamcrest 风格确认。

你还将在本章中学习如何创建自己的 Hamcrest 匹配器。我们将创建一个新的匹配器来测试不等式,而不是始终测试相等性,你将看到如何将一个匹配器包含在另一个匹配器中,这样你就可以更好地重用匹配器,而无需重复所有匹配器模板特化。

最后,你将学习如何创建另一个自定义简单匹配器,它将与其他匹配器略有不同,因为新的匹配器不需要预期值。

技术要求

本章中所有代码都使用基于任何现代 C++ 20 或更高版本编译器和标准库的标准 C++。代码基于并继续增强本书第一部分测试 MVP中的测试库。

你可以在此 GitHub 仓库中找到本章的所有代码:

[github.com/PacktPublishing/Test-Driven-Development-with-CPP](https://github.com/PacktPublishing/Test-Driven-Development-with-CPP

)

更精确的浮点数比较

当需要改进时,首先要寻找的是衡量当前设计的方法。在第五章中,添加更多确认类型,我们探讨了浮点数,我解释说,直接将任何浮点类型值(float、double 或 long double)与另一个浮点值进行比较是一个糟糕的想法。这种比较对小的舍入误差过于敏感,通常会导致两个值比较不相等。

第五章中,我向你展示了如何给比较添加一个小范围,这样只要被比较的两个数值足够接近,误差的累积就不会影响比较。换句话说,只要两个值足够接近,它们就可以比较相等。

但应该使用多大的容差?我们只是挑选了一些小的数字,这个解决方案就有效了。我们将改进这个解决方案。现在,你已经熟悉了Hamcrest.cpp

第一个函数将通过除以一个常数将浮点数转换为分数。我们将除以10,如下所示:

template <typename T>
T calculateFraction (T input)
{
    T denominator {10};
    return input / denominator;
}

这是一个模板,所以它适用于 float、double 和 long double 类型。意图是输入是一个整数,这个函数将数字转换为十分之一。记得从第五章中,十分之一在二进制中没有精确的表示。将引入一点误差,但不会太多,因为我们只做一次除法计算。

我们需要另一个函数,通过做更多的工作来生成更大的误差范围,如下所示:

template <typename T>
T accumulateError (T input)
{
    // First add many small amounts.
    T partialAmount {0.1};
    for (int i = 0; i < 10; ++i)
    {
        input += partialAmount;
    }
    // Then subtract to get back to the original.
    T wholeAmount {1};
    input -= wholeAmount;
    return input;
}

这个函数先加1然后减1,所以输入应该保持不变。但由于我们添加了许多等于1的小量,这个函数在计算过程中引入了许多错误。返回的结果应该接近原始的input,但并不相同。

最后的辅助函数将多次调用前两个函数,对许多不同的值进行计数,以查看结果相等多少次。函数看起来像这样:

template <typename T>
int performComparisons (int totalCount)
{
    int passCount {0};
    for (int i = 0; i < totalCount; ++i)
    {
        T expected = static_cast<T>(i);
        expected = calculateFraction(expected);
        T actual = accumulateError(expected);
        if (actual == expected)
        {
            ++passCount;
        }
    }
    return passCount;
}

函数使用分数作为预期值,因为它应该有最少的误差。预期值与从累积许多小误差中得到的实际值进行比较。这两个值应该很接近,但并不完全相等。尽管如此,它们应该足够接近,以至于可以被认为是相等的。

谁定义了“足够接近”是什么意思?这完全取决于你自己的决定。在这本书中我们创建的测试可能允许比你的应用程序可以容忍的更多错误。阅读这一节后,你会了解如果需要更多或更少的容忍度,如何修改你的代码。对于如何比较浮点值,没有适用于所有应用程序的正确答案。你能做的最好的事情就是意识到自己的需求,并调整代码以适应这些需求。

performComparisons函数也使用==运算符而不带任何类型的容差。结果应该有很多不相等的结果。但有多少呢?让我们写一个测试来找出答案!

将此测试添加到Hamcrest.cpp的末尾:

TEST("Test many float comparisons")
{
    int totalCount {1'000};
    int passCount = performComparisons<float>(totalCount);
    CONFIRM_THAT(passCount, Equals(totalCount));
}

测试将循环通过1,000个值,将每个值转换为十分之一,引入错误,并计算有多少个比较相等。结果真的很糟糕:

------- Test: Test many float comparisons
Failed confirm on line 125
    Expected: 1000
    Actual  : 4

只有四个值足够接近,可以用标准相等运算符被认为是相等的。你可能根据你的计算机和编译器得到略微不同的结果。如果你得到不同的结果,那么这应该更有力地证明浮点比较是多么不可靠。那么双精度和长双精度类型呢?添加这两个测试来找出答案:

TEST("Test many double comparisons")
{
    int totalCount {1'000};
    int passCount = performComparisons<double>(totalCount);
    CONFIRM_THAT(passCount, Equals(totalCount));
}
TEST("Test many long double comparisons")
{
    int totalCount {1'000};
    int passCount = performComparisons<long                     double>(totalCount);
    CONFIRM_THAT(passCount, Equals(totalCount));
}

结果同样糟糕,看起来像这样:

------- Test: Test many double comparisons
Failed confirm on line 132
    Expected: 1000
    Actual  : 4
------- Test: Test many long double comparisons
Failed confirm on line 139
    Expected: 1000
    Actual  : 0

让我们在相等比较中添加一个边缘值,看看比较会变得多好。我们将从Test.h中现有的confirm重载中使用的值开始。其中一个重载看起来像这样:

inline void confirm (
    float expected,
    float actual,
    int line)
{
    if (actual < (expected - 0.0001f) ||
        actual > (expected + 0.0001f))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

我们感兴趣的是硬编码的浮点字面值。在这种情况下,它是0.0001f。我们只需要创建三个额外的辅助函数来返回这些值。请注意,double 和 long double 的重载与 float 类型有不同的值。将这些三个辅助函数放在Hamcrest.cpp中,在performComparisons函数之前,如下所示:

constexpr float getMargin (float)
{
    return 0.0001f;
}
constexpr double getMargin (double)
{
    return 0.000001;
}
constexpr long double getMargin (long double)
{
    return 0.000001L;
}

这三个辅助函数将使我们能够为每种类型定制边缘值。它们各自接受一个浮点类型参数,该参数仅用于确定要调用的函数。我们实际上不需要或使用传递给函数的参数值。我们将在performComparisons模板内部调用这些辅助函数,该模板将根据模板的构建方式知道要使用哪种类型。

我们还将稍微改变带有边缘值的比较方式。以下是一个确认函数如何比较的示例:

    if (actual < (expected - 0.0001f) ||
        actual > (expected + 0.0001f))

而不是这样,我们将从expected值中减去actual值,然后比较这个减法结果的绝对值与边缘值。我们需要在Hamcrest.cpp的顶部包含cmath以使用abs函数,并且我们很快也需要limits,如下所示:

#include "../Test.h"
#include <cmath>
#include <limits>

现在,我们可以将performComparisons函数更改为使用边缘值,如下所示:

template <typename T>
int performComparisons (int totalCount)
{
    int passCount {0};
    for (int i = 0; i < totalCount; ++i)
    {
        T expected = static_cast<T>(i);
        expected = calculateFraction(expected);
        T actual = accumulateError(expected);
        if (std::abs(actual - expected) < getMargin(actual))
        {
            ++passCount;
        }
    }
    return passCount;
}

在做出这些更改后,所有的测试都通过了,如下所示:

------- Test: Test many float comparisons
Passed
------- Test: Test many double comparisons
Passed
------- Test: Test many long double comparisons
Passed

这意味着现在所有1,000个值都在一个很小的误差范围内匹配。这是在第五章中解释的相同解决方案。我们应该没问题,对吧?并不完全是这样。

问题在于,对于小数,边缘值很大,而对于大数,边缘值又太小。所有的测试都通过了,但这仅仅是因为我们有一个足够大的边缘值,使得很多比较都被视为相等。

为了看到这一点,让我们将比较从performComparisons函数中重构出来,使其检查在自己的函数中,如下所示:

template <typename T>
bool compareEq (T lhs, T rhs)
{
    return std::abs(lhs - rhs) < getMargin(lhs);
}
template <typename T>
int performComparisons (int totalCount)
{
    int passCount {0};
    for (int i = 0; i < totalCount; ++i)
    {
        T expected = static_cast<T>(i);
        expected = calculateFraction(expected);
        T actual = accumulateError(expected);
        if (compareEq(actual, expected))
        {
            ++passCount;
        }
    }
    return passCount;
}

然后,我们可以编写一些测试来直接调用compareEq,如下所示:

TEST("Test small float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    bool result = compareEq(0.000001f, 0.000002f);
    CONFIRM_FALSE(result);
}
TEST("Test large float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    bool result = compareEq(9'999.0f, 9'999.001f);
    CONFIRM_TRUE(result);
}

对于小浮点数的测试比较了两个明显不同的数字,但比较函数会将它们视为相等,测试失败。固定的边缘值将任何在0.0001f内的浮点值视为相等。我们希望这两个值比较时不相等,但我们的边缘值足够大,以至于它们被视为相等。

注释中提到的 epsilon 值是多少?我们很快就会开始使用实际的 epsilon 值,这就是为什么我建议你包含 limits。浮点数有一个称为 epsilon 的概念,这是为每种浮点类型在 limits 中定义的值。epsilon 值表示在 1.0 和 2.0 之间的相邻浮点值之间的最小距离。记住,浮点数不能表示每个可能的分数数,因此在可以表示的数之间有间隙。

如果你将只有固定小数位数的数字写在纸上,你也能看到相同的情况。比如说,你限制自己只使用小数点后两位数字。你可以写 1.001.011.02。这些都是相邻的值。实际上,1.001.02 是你只能使用小数点后两位数字表示的,最接近 1.01 的数值。那么一个像 1.011 这样的数呢?它肯定比 1.02 更接近 1.01,但我们不能写 1.011,因为它需要小数点后三位数字。我们实验中的 epsilon 值是 0.01。浮点数也有类似的问题,只是 epsilon 的值更小,不是一个简单的值如 0.01

另一个复杂的问题是,随着数值的增大,相邻浮点数之间的距离增加,而随着数值的减小,距离减小。小浮点数的测试使用的是小数值,但数值比 epsilon 大得多。因为数值比 epsilon 大得多,我们希望测试失败。测试通过是因为我们的固定容限甚至比 epsilon 还大。

大浮点数的测试也失败了。它使用了两个相差 0.001f 的值,如果我们比较 1.0f1.001f,这将是一个很大的差异。在小数值的情况下,0.001f 的差异足以使值比较结果不相等。但我们处理的是大数值——我们处理的是几乎 10,000 的数值!现在我们希望较大的数值被认为是相等的,因为小数部分在较大的数值中占的比例更小。测试失败是因为我们的固定容限没有考虑到数值较大,只关注了差异,而这个差异大于固定容限允许的范围。

我们也可以测试其他浮点类型。在为小浮点数和大浮点数添加的两个测试之后,立即添加这两个类似的测试,如下所示:

TEST("Test small double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    bool result = compareEq(0.000000000000001,                   0.000000000000002);
    CONFIRM_FALSE(result);
}
TEST("Test large double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    bool result = compareEq(1'500'000'000'000.0,                   1'500'000'000'000.0003);
    CONFIRM_TRUE(result);
}

对于双精度类型,我们有一个比浮点数 epsilon 值小得多的不同 epsilon 值,并且我们可以使用更多的有效数字进行操作,因此我们可以使用更多位数的数字。当我们使用浮点数时,我们仅限于大约 7 位数字。使用双精度,我们可以使用大约 16 位数字的数字。请注意,使用双精度时,我们需要一个以万亿为单位的较大值,才能看到应该视为相等的 0.0003 的差异。

如果你想知道我是如何得到这些测试数字的,我只是选择了比 epsilon 大一个小数位的较小值测试的小数。对于较大值,我选择了一个较大的数字,并将其乘以(1 + epsilon)以得到要比较的另一个数字。然后我对另一个数字进行了一些四舍五入,使其更接近一些。我必须选择一个较大的起始数字,以确保它保持在每种类型允许的位数内。

由于我们正在使用长双精度 epsilon 值,因此小长双精度和大长双精度的测试看起来与双精度的测试相似。长双精度的测试如下:

TEST("Test small long double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    bool result = compareEq(0.000000000000001L,                   0.000000000000002L);
    CONFIRM_FALSE(result);
}
TEST("Test large long double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    bool result = compareEq(1'500'000'000'000.0L,                   1'500'000'000'000.0003L);
    CONFIRM_TRUE(result);
}

双精度测试和长双精度测试之间的唯一区别是长双精度字面值末尾的 L 后缀。

在添加了所有六个针对小浮点数和大型浮点数类型测试之后,当运行时它们都失败了。

失败的原因对每种类型都是相同的。小值测试全部失败,因为固定的边距将值视为相等,而实际上它们不应该相等,并且大值测试在考虑大值时将值视为不相等,而实际上它们非常接近。事实上,大值彼此之间只有一个 epsilon 值的距离。大值尽可能接近,但不是完全相等。当然——长双精度的大值可能更接近,但我们通过使用来自双精度类型的较大 epsilon 来简化长双精度。

我们需要增强 compareEq 函数,以便对于小值,边距可以更小,对于大值,边距可以更大。当我们承担比较浮点数值的责任时,有很多细节需要处理。我们在 第五章 中跳过了额外的细节。我们甚至在这里也会跳过一些细节。如果你还没有意识到,处理浮点数值真的很困难。当你认为一切都在正常工作时,另一个细节就会出现,从而改变一切。

让我们先修复 getMargin 函数,使其返回每种类型的修改后的真实 epsilon 值,如下所示:

constexpr float getMargin (float)
{
    // 4 is chosen to pass a reasonable amount of error.
    return std::numeric_limits<float>::epsilon() * 4;
}
constexpr double getMargin (double)
{
    // 4 is chosen to pass a reasonable amount of error.
    return std::numeric_limits<double>::epsilon() * 4;
}
constexpr long double getMargin (long double)
{
    // Use double epsilon instead of long double epsilon.
    // Double epsilon is already much bigger than
    // long double epsilon so we don't need to multiply it.
    return std::numeric_limits<double>::epsilon();
}

getMargin函数现在使用在numeric_limits中定义的类型epsilon值。边缘被调整以满足我们的需求。你可能想要乘以不同的数字,你可能想要为长双精度使用实际的epsilon值。我们想要比epsilon本身更大的边缘的原因是我们想要考虑那些彼此之间相差不止一个epsilon值的数值相等。我们想要为至少几个计算误差的累积留出更多空间。我们将epsilon乘以4以提供额外的空间,并且对于长双精度,我们使用双倍的epsilon,这可能已经足够了。但这些边缘对我们来说是有效的。

我们将在新的compareEq函数中使用更精确的边缘值,该函数看起来像这样:

template <typename T>
bool compareEq (T lhs, T rhs)
{
    // Check for an exact match with operator == first.
    if (lhs == rhs)
    {
        return true;
    }
    // Subnormal diffs near zero are treated as equal.
    T diff = std::abs(lhs - rhs);
    if (diff <= std::numeric_limits<T>::min())
    {
        return true;
    }
    // The margin should get bigger with bigger absolute values.
    // We scale the margin up by the larger value or
    // leave the margin unchanged if larger is less than 1.
    lhs = std::abs(lhs);
    rhs = std::abs(rhs);
    T larger = (lhs > rhs) ? lhs : rhs;
    larger = (larger < 1.0) ? 1.0 : larger;
    return diff <= getMargin(lhs) * larger;
}

我喜欢为像这样的操作符类型函数使用参数名称lhsrhs。这些缩写分别代表左端和右端。

考虑这两个数字:

3 == 4

当进行这些比较时,3位于操作符的左侧,将是lhs参数,而4位于右侧,将是rhs参数。

总是有可能被比较的两个数值完全相等。所以,我们首先使用==操作符检查是否完全匹配。

compareEq函数继续检查两个数值之间的差异以获得一个异常值结果。记得我说过浮点数很复杂吗?可能有一整本书是关于浮点数学的,可能已经有几本书写过了。我不会过多解释异常值,只是说这是当浮点值非常接近零时如何表示的。我们将认为任何两个异常值都是相等的。

异常值也是用比较你的数值之间而不是比较它们的差值与零的一个很好的理由。你可能想知道问题是什么。compareEq函数中的代码不是从另一个值中减去一个值来得到差值吗?是的,它是这样做的。但我们的compareEq函数并不试图直接将差值与零进行比较。我们找出两个值中哪个更大,然后通过将边缘与较大的值相乘来缩放边缘。我们还在比较小于1.0的值时避免缩小边缘。

如果你有两个值需要比较,并且不是将它们传递给compareEq函数,而是传递它们的差值,并将差值与零进行比较,那么你就移除了compareEq函数进行缩放的能力,因为compareEq函数将只会看到一个很小的差值和与零的比较。

这里的教训是始终直接将你要比较的数值传递给compareEq函数,并让它通过考虑数值的大小来确定两个数值之间的差异。你会得到更准确的比较。

我们甚至可以使 compareEq 函数更加精细。也许我们可以考虑次正常值的符号,而不是将它们都视为相等,或者我们可以将边界值缩小更多,以便在处理次正常值时非常精确。这不是一本关于数学的书,所以我们将在 compareEq 函数中停止添加更多内容。

在对 compareEq 进行更改后,所有测试都通过了。我们现在有一个解决方案,允许少量的累积误差,并且当两个数字足够接近时,它们可以比较相等。该解决方案适用于非常小的数字和非常大的数字。下一节将把在这里探索的代码转换成一个更好的 Hamcrest 等价匹配器。

添加浮点数 Hamcrest 匹配器

我们在上一节中探讨了更好的浮点数比较,现在是时候在单元测试库中使用比较代码了。一些代码应该移动到 Test.h 中,那里更适合,然后可以被测试库使用。其余的代码应该保留在 Hamcrest.cpp 中,因为它是支持测试的代码。

需要移动的代码是 compareEq 函数和 compareEq 调用来获取边界的三个 getMargin 函数。我们还需要将 cmathlimits 的包含文件移动到 Test.h 中,如下所示:

#include <cmath>
#include <cstring>
#include <limits>
#include <map>
#include <ostream>
#include <string_view>
#include <type_traits>
#include <vector>

三个 getMargin 函数和 compareEq 函数可以被移动到 Test.h 文件中,紧接在第一个接受布尔值的 confirm 函数重写之前。移动的函数中的代码无需更改。只需从 Hamcrest.cpp 中剪切包含和函数,然后将代码粘贴到 Test.h 中。

我们不妨修复现有的浮点数经典 confirm 函数。这就是为什么我让你立即将 compareEq 函数移动到 Test.h 中,紧接在第一个 confirm 函数之前。对现有浮点数 confirm 函数的更改很简单。它们需要调用 compareEq 而不是使用硬编码的边界值,这些边界值不会缩放。更改后的浮点类型 confirm 函数如下所示:

inline void confirm (
    float expected,
    float actual,
    int line)
{
    if (not compareEq(actual, expected))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

接受双精度浮点数和长双精度浮点数的其他两个 confirm 函数应该被修改得相似。所有三个 confirm 函数都将根据 expectedactual 参数类型创建正确的 compareEq 模板。

我们应该构建并运行测试应用程序,以确保这次小的重构没有破坏任何东西。并且所有测试都通过了。我们现在有了更新的经典风格 confirm 函数,它们将更好地与浮点数比较一起工作。

尽管如此,我们可以使代码稍微好一些。我们有三个几乎完全相同的函数,它们的不同之处仅在于它们的参数类型。这三个函数的唯一原因是我们想要覆盖浮点类型的 confirm 函数。但是,由于我们正在使用 C++20,让我们使用 concepts 代替!Concepts 是一个新特性,我们在上一章专门化 Equals 匹配器以与字符数组和字符指针一起使用时已经开始使用它。Concepts 允许我们告诉编译器哪些类型是模板参数和函数参数的可接受类型。在上一章中,我们只使用 requires 关键字对模板参数施加一些限制。在本章中,我们将使用更多知名的概念。

我们需要在 Test.h 中包含这样的 concepts

#include <concepts>
#include <cmath>
#include <cstring>
#include <limits>
#include <map>
#include <ostream>
#include <string_view>
#include <type_traits>
#include <vector>

然后,我们可以用单个模板替换接受 float、double 和 long double 类型的三个 confirm 函数,这个模板使用 floating_point 概念,如下所示:

template <std::floating_point T>
void confirm (
    T expected,
    T actual,
    int line)
{
    if (not compareEq(actual, expected))
    {
        throw ActualConfirmException(
            std::to_string(expected),
            std::to_string(actual),
            line);
    }
}

这个新模板将只接受浮点类型,通过使 expectedactual 共享相同的类型 T,那么这两种类型必须相同。floating_point 的定义是 concepts 头文件中定义的已知概念之一。

现在我们已经使经典风格的确认工作正常,让我们让 Hamcrest 的 Equals 匹配器为浮点值工作。我们首先可以将 Hamcrest.cpp 中的三个大型浮点测试更改,停止直接调用 compareEq,而是使用 CONFIRM_THAT 宏,使它们看起来像这样:

TEST("Test large float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    CONFIRM_THAT(9'999.0f, Equals(9'999.001f));
}
TEST("Test large double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(1'500'000'000'000.0,                 Equals(1'500'000'000'000.0003));
}
TEST("Test large long double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(1'500'000'000'000.0L,             Equals(1'500'000'000'000.0003L));
}

我们现在不会更改小浮点值的测试,因为我们还没有一个可以进行不等式比较的匹配器。解决方案可能很简单,只需在 Equals 前面加上 not 关键字,但让我们稍后再考虑这一点,因为我们将探索我们的选项在下一段中。

随着测试的更改,它们应该会失败,因为我们还没有将 Equals 匹配器专门化以对浮点类型执行不同的操作。构建和运行测试应用程序显示,这三个测试确实失败了,如下所示:

------- Test: Test large float values
Failed confirm on line 152
    Expected: 9999.000977
    Actual  : 9999.000000
------- Test: Test small double values
Passed
------- Test: Test large double values
Failed confirm on line 165
    Expected: 1500000000000.000244
    Actual  : 1500000000000.000000
------- Test: Test small long double values
Passed
------- Test: Test large long double values
Failed confirm on line 178
    Expected: 1500000000000.000300
    Actual  : 1500000000000.000000

注意,总结报告中打印的预期值并不完全匹配测试中给出的浮点类型和双精度类型的字面值。长双精度确实显示了一个与测试中给出的值匹配的值。这种差异是因为浮点变量无法始终匹配精确值。差异在浮点数中更为明显,在双精度数中稍微不明显,而在长双精度数中则更接近期望值。

我们刚才采取的步骤遵循 TDD(测试驱动开发)。我们修改了现有测试而不是创建新测试,因为我们不期望调用者直接使用 compareEq。测试最初是编写来直接调用 compareEq 以表明我们为浮点类型提供了一个解决方案。将测试修改为期望的使用方法是正确的事情,然后,通过运行测试,我们可以看到失败。这是好的,因为我们预计测试会失败。如果测试通过了,那么我们就需要找到意外成功的原因。

让我们再次通过测试!我们需要一个能够处理浮点类型的 Equals 版本。我们将使用之前用于经典风格确认的 floating_point 概念来创建另一个版本的 Equals,该版本将为浮点类型调用 compareEq。将这个新的 Equals 特化版本放在 Test.h 中,紧接在处理字符指针的 Equals 之后,如下所示:

template <std::floating_point T>
class Equals<T> : public Matcher
{
public:
    Equals (T const & expected)
    : mExpected(expected)
    { }
    bool pass (T const & actual) const
    {
        return compareEq(actual, mExpected);
    }
    std::string to_string () const override
    {
        return std::to_string(mExpected);
    }
private:
    T mExpected;
};

这就是我们需要的所有更改,以使测试再次通过。新的 Equals 特化版本接受任何浮点类型,并且编译器将优先选择它而不是通用 Equals 模板来处理浮点类型。Equals 的浮点版本调用 compareEq 来进行比较。我们也不必担心将传递给 to_string 的类型,因为我们知道我们将有一个内置的浮点类型。如果用户传递了一个其他类型,该类型已被创建为 floating_point 概念类型,则 to_string 假设可能会失败,但让我们现在保持代码尽可能简单,不要担心自定义浮点类型。

下一节将首先创建一个用于测试不等式的解决方案。我们将使用下一节中创建的解决方案来修改小的浮点 Hamcrest 测试。

编写自定义的 Hamcrest 匹配器

上一节以将 Equals 匹配器特化为调用 compareEq 以处理浮点类型结束。我们还修改了大的浮点值测试,因为它们可以使用 Hamcrest 风格和 Equals 匹配器。

我们没有改变小的浮点值测试,因为这些测试需要确保实际值和预期值不相等。

我们想要更新小的浮点值测试,并需要一个方法来测试不等值。也许我们可以创建一个新的匹配器,称为 NotEquals,或者我们可以在 Equals 匹配器前面放置 not 关键字。

如果可能,我想避免需要一个新的匹配器。我们并不真的需要任何新的行为——我们只需要翻转现有 Equals 匹配器的结果。让我们尝试修改小的浮点值测试,使其在 Hamcrest.cpp 中看起来像这样:

TEST("Test small float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    CONFIRM_THAT(0.000001f, not Equals(0.000002f));
}
TEST("Test small double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(0.000000000000001,             not Equals(0.000000000000002));
}
TEST("Test small long double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(0.000000000000001L,             not Equals(0.000000000000002L));
}

唯一的改变是停止直接调用 compareEq 并使用 CONFIRM_THAT 宏和 Equals 匹配器。注意,我们通过在前面放置 not 关键字来反转 Equals 匹配器的结果。

它能构建吗?不。我们得到了类似的编译错误:

MereTDD/tests/Hamcrest.cpp:145:29: error: no match for 'operator!' (operand type is 'MereTDD::Equals<float>')
  145 |     CONFIRM_THAT(0.000001f, not Equals(0.000002f));
      |                             ^~~~~~~~~~~~~~~~~~~~~

C++ 中的 not 关键字是 operator ! 的快捷方式。通常在使用 TDD(测试驱动开发)时,下一步是修改代码以便测试可以构建。但我们遇到了一个问题。not 关键字期望类有一个 operator ! 方法或者某种将类转换为布尔值的方式。这两种选择都需要类能够生成布尔值,但这并不是匹配器的工作方式。为了使匹配器知道结果是否应该通过,它需要知道 actual(实际)值。confirm_that 函数通过将所需的 actual 值作为参数传递给 pass 方法,将匹配器传递给 pass 方法。我们不能仅仅将匹配器本身转换为布尔结果。

我们将不得不创建一个 NotEquals 匹配器。虽然这不是我的首选,但从测试的角度来看,一个新的匹配器是可以接受的。让我们将测试改为如下所示:

TEST("Test small float values")
{
    // Based on float epsilon = 1.1920928955078125e-07
    CONFIRM_THAT(0.000001f, NotEquals(0.000002f));
}
TEST("Test small double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(0.000000000000001,             NotEquals(0.000000000000002));
}
TEST("Test small long double values")
{
    // Based on double epsilon = 2.2204460492503130808e-16
    CONFIRM_THAT(0.000000000000001L,             NotEquals(0.000000000000002L));
}

我想要避免创建一个新的匹配器另一个原因是避免像为 Equals 匹配器所做的那样专门化新的匹配器,但有一种方法可以创建一个名为 NotEquals 的匹配器,并基于我们为 Equals 匹配器所做的所有工作来实现它。我们只需要包含 Equals 匹配器并反转 pass 结果,如下所示:

template <typename T>
class NotEquals : public Matcher
{
public:
    NotEquals (T const & expected)
    : mExpected(expected)
    { }
    template <typename U>
    bool pass (U const & actual) const
    {
        return not mExpected.pass(actual);
    }
    std::string to_string () const override
    {
        return "not " + mExpected.to_string();
    }
private:
    Equals<T> mExpected;
};

Test.h 中,在 Equals 匹配器的所有模板专门化之后添加 NotEquals 匹配器。

NotEquals 匹配器是一个包含 Equals 匹配器作为其 mExpected 数据成员的新匹配器类型。这将给我们所有为 Equals 匹配器所做的专门化。每当调用 NotEquals::pass 方法时,我们只需调用 mExpected.pass 方法并反转结果。每当调用 to_string 方法时,我们只需将 "not " 添加到 mExpected 提供的任何字符串中。

一个有趣的现象是,pass 方法本身就是一个基于类型 U 的模板。这将使我们能够根据一个字符串字面量构建一个 NotEquals 匹配器,然后使用 std::string 调用 pass 方法。

我们应该添加一个测试来使用 NotEquals 匹配器与字符串字面量和 std::string,或者更好的是,扩展现有的测试。我们已经有两个测试与字符串、字符串字面量和字符指针一起工作。这两个测试都在 Hamcrest.cpp 中。第一个测试应该看起来像这样:

TEST("Test hamcrest style string confirms")
{
    std::string s1 = "abc";
    std::string s2 = "abc";
    CONFIRM_THAT(s1, Equals(s2));       // string vs. string
    CONFIRM_THAT(s1, Equals("abc"));    // string vs. literal
    CONFIRM_THAT("abc", Equals(s1));    // literal vs. string
    // Probably not needed, but this works too.
    CONFIRM_THAT("abc", Equals("abc")); // literal vs. literal
    std::string s3 = "def";
    CONFIRM_THAT(s1, NotEquals(s3));       // string vs. string
    CONFIRM_THAT(s1, NotEquals("def"));    // string vs. literal
    CONFIRM_THAT("def", NotEquals(s1));    // literal vs. string
}

第二个测试应该修改为如下所示:

TEST("Test hamcrest style string pointer confirms")
{
    char const * sp1 = "abc";
    std::string s1 = "abc";
    char const * sp2 = s1.c_str();    // avoid sp1 and sp2 being same
    CONFIRM_THAT(sp1, Equals(sp2));   // pointer vs. pointer
    CONFIRM_THAT(sp2, Equals("abc")); // pointer vs. literal
    CONFIRM_THAT("abc", Equals(sp2)); // literal vs. pointer
    CONFIRM_THAT(sp1, Equals(s1));    // pointer vs. string
    CONFIRM_THAT(s1, Equals(sp1));    // string vs. pointer
    char const * sp3 = "def";
    CONFIRM_THAT(sp1, NotEquals(sp3));   // pointer vs. pointer
    CONFIRM_THAT(sp1, NotEquals("def")); // pointer vs. literal
    CONFIRM_THAT("def", NotEquals(sp1)); // literal vs. pointer
    CONFIRM_THAT(sp3, NotEquals(s1));    // pointer vs. string
    CONFIRM_THAT(s1, NotEquals(sp3));    // string vs. pointer
}

构建和运行测试应用程序显示所有测试都通过了。我们没有添加新的测试,而是能够修改现有的测试,因为这两个现有的测试都集中在字符串和字符指针类型上。NotEquals 匹配器完美地融入了现有的测试中。

拥有EqualsNotEquals匹配器比经典风格的确认方式给我们提供了更多,我们可以通过创建另一个匹配器来更进一步。你还可以创建匹配器来在你的测试项目中执行任何你想要的操作。我们将在MereTDD命名空间中创建一个新的匹配器,但你也可以将你的匹配器放在你自己的命名空间中。我们将创建的匹配器将测试以确保一个整数是偶数。我们将称这个匹配器为IsEven,我们可以在Hamcrest.cpp中编写几个测试,如下所示:

TEST("Test even integral value")
{
    CONFIRM_THAT(10, IsEven<int>());
}
TEST("Test even integral value confirm failure")
{
    CONFIRM_THAT(11, IsEven<int>());
}

你会注意到IsEven匹配器的一个不同之处:它不需要预期的值。匹配器只需要传递给它的实际值,以确认实际值是否为偶数。因为创建测试中的IsEven匹配器时没有东西可以传递给构造函数,我们需要指定类型,如下所示:

IsEven<int>()

第二次测试应该失败,我们将利用这个失败来获取确切的错误信息,以便我们可以将测试转换为预期的失败。但首先我们需要创建一个IsEven匹配器。IsEven类可以放在Test.h中,紧随NotEquals匹配器之后,如下所示:

template <std::integral T>
class IsEven : public Matcher
{
public:
    IsEven ()
    { }
    bool pass (T const & actual) const
    {
        return actual % 2 == 0;
    }
    std::string to_string () const override
    {
        return "is even";
    }
};

我想给你展示一个真正简单的自定义匹配器的例子,这样你就会知道它们并不都需要复杂或者有多个模板特化。IsEven匹配器只测试pass方法中的actual值以确保它是偶数,而to_string方法返回一个固定的字符串。

构建和运行显示,偶数值测试通过,而预期失败的测试失败,如下所示:

------- Test: Test even integral value
Passed
------- Test: Test even integral value confirm failure
Failed confirm on line 185
    Expected: is even
    Actual  : 11

有错误信息,我们可以修改偶数确认失败测试,使其以预期的失败通过,如下所示:

TEST("Test even integral value confirm failure")
{
    std::string reason = "    Expected: is even\n";
    reason += "    Actual  : 11";
    setExpectedFailureReason(reason);
    CONFIRM_THAT(11, IsEven<int>());
}

现在构建和运行显示,两个测试都通过了。一个成功通过,另一个以预期的失败通过,如下所示:

------- Test: Test even integral value
Passed
------- Test: Test even integral value confirm failure
Expected failure
    Expected: is even
    Actual  : 11

这就是制作自定义匹配器的全部内容!你可以为你的类创建匹配器,或者为新的行为添加自定义匹配器。也许你想要验证一个数字只有一定数量的数字,一个字符串以某个给定的文本前缀开始,或者一个日志消息包含某个特定的标签。你还记得在第十章深入理解 TDD 过程中,我们不得不通过写入文件然后扫描文件来验证标签吗?我们可以有一个自定义匹配器来查找标签。

摘要

Hamcrest 风格确认的主要优点之一是它们可以通过自定义匹配器进行扩展。还有什么比通过浮点数确认来探索这种能力更好的方法呢?因为比较浮点值没有唯一最佳的方式,你可能需要一个针对你特定需求进行调优的解决方案。在本章中,你了解了一种良好的通用浮点数比较技术,它将小的误差范围进行缩放,使得较大的浮点值在值变大时可以允许有更大的差异,但仍被视为相等。

如果这个通用解决方案不能满足你的需求,你现在知道如何创建自己的匹配器,使其正好满足你的需求。

而扩展匹配器的功能并不仅限于浮点值。你可能有自己的自定义行为需要确认,在阅读本章之后,你现在知道如何创建一个自定义匹配器来完成你所需要的工作。

并非所有匹配器都需要庞大且复杂,以及拥有多个模板特化。你看到了一个非常简单的自定义匹配器的例子,它确认一个数字是否为偶数。

我们还很好地利用了 C++20 中新引入的概念特性,该特性允许你轻松地指定对模板类型的约束。我们在本章中很好地利用了概念,以确保浮点数匹配器仅适用于浮点类型,并且 IsEven 匹配器仅适用于整型类型。你同样可以在你的匹配器中使用概念,这有助于你控制匹配器的使用方式。

下一章将探讨如何测试服务,并介绍一个使用本书迄今为止开发的所有代码的新服务项目。

第十四章:如何测试服务

我们已经积累到这个阶段,可以使用测试库和日志库在另一个项目中。日志库的客户始终是使用 TDD 设计更好服务的微服务 C++ 开发者。

由于专注于服务,本章将介绍一个模拟微服务的项目。我们不会包括真实服务所需的所有内容。例如,真实的服务需要网络、路由和排队请求的能力以及处理超时。我们的服务将只包含启动服务和处理请求的核心方法。

你将了解测试服务所涉及到的挑战,以及测试服务与测试试图做所有事情的应用程序的不同之处。本章将较少关注服务的设计。我们也不会编写所有需要的测试。实际上,本章只使用了一个测试。还提到了可以添加的其他测试。

我们还将探讨在服务中可以测试的内容,以及一些提示和指导,这将使你能够在调试服务时控制生成的日志量。

服务项目将帮助将测试和日志库结合起来,并展示如何在你的项目中使用这两个库。

本章的主要内容包括以下几项:

  • 服务测试挑战

  • 在服务中可以测试什么?

  • 介绍 SimpleService 项目

技术要求

本章中所有代码都使用标准 C++,它基于任何现代 C++ 20 或更高版本的编译器和标准库。代码引入了一个新的服务项目,该项目使用了本书第一部分“测试 MVP”中的测试库,并使用了本书第二部分“日志库”中的日志库。

你可以在以下 GitHub 仓库中找到本章的所有代码:

github.com/PacktPublishing/Test-Driven-Development-with-CPP

)

服务测试挑战

在本书中我们一直在思考的客户是一位使用 C++ 编写服务的微服务开发者,他希望更好地理解 TDD 以改进开发过程并提高代码质量。TDD 适用于任何编写代码的人。但为了遵循 TDD,你需要清楚地了解你的客户是谁,这样你才能从客户的角度编写测试。

与测试一个自己完成所有事情的应用程序相比,在测试服务时会有不同的挑战。通常将包含所有内容的程序称为单体应用程序。适用于服务的挑战示例包括:

  • 服务是否可达?

  • 服务是否正在运行?

  • 服务是否因其他请求而超载?

  • 是否有任何权限或安全检查可能影响你调用服务的能力?

然而,在我们深入探讨之前,我们需要了解什么是服务以及为什么你应该关心。

服务独立运行并接收请求,处理请求,并为每个请求返回某种类型的响应。服务专注于请求和响应,这使得它们更容易编写和调试。你不必担心其他代码以意想不到的方式与你的服务交互,因为请求和响应完全定义了交互。如果你的服务开始收到过多的请求,你总是可以添加更多服务实例来处理额外的负载。当服务专注于处理几个特定的请求时,它们被称为微服务。当你能够将工作分解为微服务时,构建大型和复杂解决方案变得更加容易和可靠。

服务也可以向其他服务发出请求以处理请求。这就是微服务如何相互构建以形成更大服务的方式。在每一步,请求和预期响应都是清晰和明确定义的。也许你的整个解决方案完全由服务组成。但更有可能的是,你将有一个客户运行的应用程序,该应用程序接受客户的输入和指示,并向各种服务发出请求以满足客户的需求。也许客户打开一个应用程序窗口,显示基于客户提供的一些日期的信息图表。为了获取显示图表所需的数据,应用程序会将日期作为请求发送到提供数据的服务的服务。该服务甚至可以根据发出请求的具体客户定制数据。

想象一下,如果编写一个试图自己完成所有工作的应用程序会有多困难。开发工作量可能会从难以想象的复杂单体应用程序转变为使用服务时的合理工作量。当任务可以隔离和独立开发、管理时,质量也会提高。

服务通常运行在多台计算机上,因此请求和响应是通过网络进行的。也可能涉及其他路由代码,它接受请求并在将其发送到服务之前将其放入队列。服务可能运行在多台计算机上,路由器将确定哪个服务最能处理请求。

如果你足够幸运,拥有一个庞大且设计良好的服务网络,那么你可能会拥有多个独立的网络,旨在帮助你测试和部署服务。每个网络可以拥有许多不同的计算机,每台计算机可以运行多个不同的服务。这就是路由器变得非常有用的地方。

测试运行在多个网络中的服务通常涉及在为早期测试设计的网络中的一台计算机上部署要测试的服务的新版本。这个网络通常被称为开发环境

如果开发环境中的测试失败,那么你有时间找到错误,进行更改,并测试新版本,直到服务按预期运行。查找错误涉及查看响应以确保它们是正确的,检查日志文件以确保在过程中采取了正确的步骤,并查看任何其他输出,例如在处理请求时可能被修改的数据库条目。根据服务,你可能还有其他需要检查的事项。

一些服务依赖于存储在数据库中的数据来正确响应请求。在开发环境中保持数据库的当前状态可能很困难,这就是为什么通常需要其他环境。如果在开发环境中初始测试通过,那么你可能会将服务更改部署到测试环境并再次测试。最终,你将把服务部署到生产环境,在那里它将为客户提供服务。

如果你可以控制请求的路由,那么在测试你的更改时运行调试器可能是可能的。这样做的方法是在调试器下启动特定计算机上的服务。通常,这只会发生在开发环境中。然后,你需要确保通过测试用户账户发出的任何请求都路由到运行调试器的计算机。同一服务(没有你的最近更改)可能正在同一环境中的其他计算机上运行,这就是为什么只有当你能确保请求将被路由到你所使用的计算机时,使用调试器进行调试才有效。

如果你没有能力将请求路由到特定的计算机,或者如果你在一个不允许调试器的环境中进行测试,那么你将不得不严重依赖日志消息。有时你事先不知道环境中哪台计算机将处理请求,因此你需要将你的服务部署到该环境中的所有计算机上。

检查日志文件可能很繁琐,因为你需要访问每一台计算机来打开日志文件,看看你的测试请求是否在该计算机上处理,或者在其他计算机上处理。如果你有一个从每台计算机收集日志文件并使日志消息可供搜索的服务,那么你将在具有多台计算机的环境中测试你的服务时遇到许多便利。

当测试不使用服务的单个应用程序时,你不会遇到相同的分布式测试问题。你甚至可以使用自己的计算机进行大部分测试。你可以在调试器下运行你的更改,检查日志文件,并快速直接地运行单元测试。服务需要更多的支持,例如你可能无法在自己的计算机上设置的消息路由基础设施。

每个使用微服务构建解决方案的公司和组织都会有不同的环境和部署步骤。我无法告诉你如何测试你特定的服务。这也不是本节的目标。我只是在解释测试服务时遇到的挑战,这些挑战与测试试图做所有事情的应用程序不同。

即使有所有的额外网络和路由,服务仍然是一个设计大型应用程序的好方法。谁知道呢,路由甚至可能本身就是一个服务。有了所有隔离和独立的服务,我们就可以通过小步骤添加新功能和升级用户体验,而不是发布一个包含所有功能的新版本。

对于小型应用程序,使用服务可能不值得额外的开销。但我看到很多小型应用程序成长为大型应用程序,然后在复杂性变得过高时陷入困境。同样的事情也发生在服务和它们所使用的语言上。我看到一些服务最初非常小,可以用几行 Python 代码编写。开发者可能面临紧迫的截止日期,用 Python 编写小型服务比用 C++编写同样的服务要快。最终,这个小型服务被证明对其他团队有价值,并在使用和功能上增长。它继续增长,直到需要用用 C++编写的服务来替换它。

现在你对测试服务的挑战有了更多了解,下一节将探讨可以测试的内容。

在服务中可以测试什么?

服务是由接受的请求和返回的响应定义的。服务还将有一个地址或某种将请求路由到服务的方法。可能有一个版本号,或者版本可能包含在地址中。

当把这些东西放在一起时,首先需要准备一个请求并将请求发送到服务。响应可能一次性到来,也可能分批到来。或者,响应可能像一张可以在以后向同一服务或不同服务出示的票证,以获取实际响应。

所有这些都意味着与服务的交互方式有很多种。唯一保持不变的是请求和响应的基本概念。如果你发出一个请求,不能保证服务会收到这个请求。如果服务回复,也不能保证响应会返回给原始请求者。处理超时始终是与服务一起工作时的一大关注点。

你可能不会想直接测试超时,因为这可能需要从 30 秒到 5 分钟的时间,服务请求才会因为无响应而被终止。但你可能想测试在预期和合理的时间内的响应时间。然而,对于这类测试要小心,因为它们有时会通过,有时会失败,这取决于许多可能变化且不受测试直接控制的因素。超时测试更像是一种压力测试或验收测试,尽管它可能在服务部署后帮助识别不良设计,但最初关注超时通常不是 TDD 的正确选择。

相反,将服务视为你将使用 TDD 进行设计的任何其他软件。明确了解客户是谁以及他们的需求是什么,然后提出一个既合理、易于使用又易于理解的需求和响应。

在测试服务时,可能只需确保响应包含正确的信息就足够了。这可能适用于完全不受你控制的服务。但对于服务来说,保持与任何调用代码完全分离,并通过请求和响应进行交互可能是有用的。

可能你正在调用由不同公司创建的服务,而响应是获取所需信息的唯一方式。如果是这样,那么你为什么要测试这个服务呢?请记住,只测试你的代码。

假设这是你正在设计和测试的服务,并且响应完全包含了所需的信息,那么你可以编写只需要形成请求并检查响应的测试。

有时,一个请求可能会产生一个仅仅确认请求的响应。实际请求的结果可能出现在其他地方。在这种情况下,你需要编写形成请求、验证响应,并在任何地方验证实际结果的测试。比如说,你正在设计一个允许调用者请求删除文件的服务。请求将包含有关文件的信息。响应可能只是确认文件已被删除。然后测试可能需要检查文件曾经所在文件夹,以确保文件不再可用。

通常,要求服务执行某些操作的请求将需要验证该操作是否真的被执行。而要求服务计算某些内容或返回某些内容的请求可能能够在响应中直接确认信息。如果请求的信息真的很大,那么找到其他方式返回信息可能更好。

无论你如何设计你的服务,主要的一点是存在许多选项。在编写测试以创建设计时,你需要考虑你的服务将如何被使用。

你甚至可能需要在测试中调用两个或更多的服务。例如,如果你正在编写一个旨在用具有较慢计算响应时间的老旧服务替换的服务,你可能想调用这两个服务并比较返回的信息,以确保新服务仍然返回与旧服务相同的信息。

服务在请求格式化和路由以及响应解释方面涉及很多开销。测试服务并不像简单地调用一个函数。

然而,在某个内部点上,一个服务将包含一个用于处理或处理请求的函数。这个函数通常不会对服务的用户公开。用户必须通过服务接口进行操作,这涉及到通过网络连接路由请求和响应。

由于 C++ 尚未具备标准网络功能,这些功能可能在 C++23 中出现,我们将跳过所有网络和官方请求和响应定义。我们将创建一个类似真实服务内部结构的简单服务。

我们还将关注那种可以在响应中完全返回信息的请求类型。下一节将介绍该服务。

介绍 SimpleService 项目

我们将在本节中开始一个新的项目来构建一个服务。就像日志项目使用测试项目一样,这个服务项目将使用测试项目。服务将更进一步,并使用日志项目。这个服务不会是一个真正的服务,因为一个完整的服务需要大量的非标准 C++ 支持代码,这将使我们进入与学习 TDD 无关的主题。

该服务将被命名为 SimpleService,初始文件集将结合本书中已经解释的许多主题。以下是项目结构:

SimpleService project root folder
    MereTDD folder
        Test.h
    MereMemo folder
        Log.h
    SimpleService folder
        tests folder
            main.cpp
            Message.cpp
            SetupTeardown.cpp
            SetupTeardown.h
        LogTags.h
        Service.cpp
        Service.h

当我开始这个项目时,我不知道需要哪些文件。我知道这个项目将使用 MereTDDMereMemo,并且会有一个用于服务的独立文件夹。在 SimpleService 文件夹内,我知道将有一个包含 main.cpptests 文件夹。我猜测还会有 Service.hService.cpp。我还为第一个测试添加了一个名为 Message.cpp 的文件。第一个测试的想法是发送一个请求并接收一个响应。

因此,让我们从我知道的项目文件开始。Test.hLog.h 是我们在这本书中迄今为止一直在开发的相同文件,而 main.cpp 文件看起来也类似,如下所示:

#include <MereTDD/Test.h>
#include <iostream>
int main ()
{
    return MereTDD::runTests(std::cout);
}

main.cpp 文件实际上比以前简单一些。我们不需要使用任何默认的日志标签,因此不需要包含任何关于日志的内容。我们只需要包含测试库并运行测试。

我编写的第一个测试放在了 Message.cpp 中,看起来如下:

#include "../Service.h"
#include <MereTDD/Test.h>
using namespace MereTDD;
TEST("Request can be sent and response received")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Hi, " + user;
    SimpleService::Service service;
    service.start();
    std::string response = service.handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

当时我的想法是,会有一个名为 Service 的类可以被构造和启动。一旦服务启动,我们可以调用一个名为 handleRequest 的方法,该方法需要一个用户 ID、服务路径和请求。handleRequest 方法将返回响应,这将是一个字符串。

请求也将是一个字符串,我决定使用一个简单的问候服务。请求将是 "Hello" 字符串,响应将是 "Hi, " 后跟用户 ID。我在测试中添加了 Hamcrest 风格的响应确认。

我意识到我们最终还需要其他测试,并且其他测试应该使用已经启动的服务。重用已经运行的服务比每次运行测试时创建服务实例并启动服务要好。因此,我将 Message.cpp 文件更改为使用具有设置和清理的测试套件,如下所示:

#include "../Service.h"
#include "SetupTeardown.h"
#include <MereTDD/Test.h>
using namespace MereTDD;
TEST_SUITE("Request can be sent and response received", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Hi, " + user;
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

本章我们将要添加到服务中的唯一测试。它将足够发送一个请求并获取一个响应。

我将 SetupTeardown.hSetupTeardown.cpp 文件添加到 tests 文件夹中。头文件看起来是这样的:

#ifndef SIMPLESERVICE_TESTS_SUITES_H
#define SIMPLESERVICE_TESTS_SUITES_H
#include "../Service.h"
#include <MereMemo/Log.h>
#include <MereTDD/Test.h>
class ServiceSetup
{
public:
    void setup ()
    {
        mService.start();
    }
    void teardown ()
    {
    }
    SimpleService::Service & service ()
    {
        return mService;
    }
private:
    SimpleService::Service mService;
};
extern MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1;
#endif // SIMPLESERVICE_TESTS_SUITES_H

这个文件包含的内容你在这本书中都已经见过。除了我们之前在单个测试 .cpp 文件中声明了设置和清理类。这是我们第一次需要在头文件中声明设置和清理,以便以后在其他测试文件中重用。你可以看到 setup 方法调用了服务的 start 方法。唯一的真正区别是全局实例 gService1 需要声明为 extern,这样我们就不会在其他使用相同设置和清理代码的测试文件中遇到链接错误。

SetupTeardown.cpp 文件看起来是这样的:

#include "SetupTeardown.h"
MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1("Greeting Service", "Service 1");

这只是头文件中声明的 externgService1 实例。套件名称 "Service 1" 需要与 Message.cpp 中的 TEST_SUITE 宏使用的套件名称相匹配。

接下来,我们看看 Service.h 中的 Service 类声明,它看起来是这样的:

#ifndef SIMPLESERVICE_SERVICE_H
#define SIMPLESERVICE_SERVICE_H
#include <string>
namespace SimpleService
{
class Service
{
public:
    void start ();
    std::string handleRequest (std::string const & user,
        std::string const & path,
        std::string const & request);
};
} // namespace SimpleService
#endif // SIMPLESERVICE_SERVICE_H

我将服务代码放在了 SimpleService 命名空间中,你可以在原始测试和设置和清理代码中看到。start 方法不需要参数,并返回空值。至少现在是这样。我们总是可以在以后增强服务。我觉得从一开始就包括启动服务的想法很重要,即使目前没有太多的事情要做。一个服务已经运行并等待处理请求的想法是定义服务是什么的核心概念。

另一个方法是 handleRequest 方法。我们跳过了真实服务的一些细节,例如请求和响应的定义。真实的服务将会有一种文档化的方式来定义请求和响应,几乎就像一种编程语言本身。我们只是将请求和响应都使用字符串。

真实的服务会使用身份验证和授权来验证用户以及每个用户可以使用服务做什么。我们只是简单地将字符串用作user身份标识。

并且一些服务有一个称为服务路径的概念。路径不是服务的地址。在编程术语中,路径就像调用栈。通常,当应用程序调用服务时,路由器会开始路径。path参数充当调用本身的唯一标识符。如果服务需要调用其他服务来处理请求,那么这些附加服务请求的路由器将添加到已经启动的初始path中。每次path增长时,路由器都会在path的末尾添加另一个唯一标识符。path可以在服务中用于记录消息。

path的整个目的是为了让开发者能够通过关联和排序特定请求的日志消息来理解日志消息。记住,服务一直在处理来自不同用户的请求。调用其他服务会导致这些服务记录它们自己的活动。拥有一个可以识别单个服务请求及其所有相关服务调用的path,即使跨越多个日志文件,在调试时也非常有帮助。

服务的实现位于Service.cpp中,内容如下:

#include "Service.h"
#include "LogTags.h"
#include <MereMemo/Log.h>
void SimpleService::Service::start ()
{
    MereMemo::FileOutput appFile("logs");
    MereMemo::addLogOutput(appFile);
    MereMemo::log(info) << "Service is starting.";
}
std::string SimpleService::Service::handleRequest (
    std::string const & user,
    std::string const & path,
    std::string const & request)
{
    MereMemo::log(debug, User(user), LogPath(path))
        << "Received: " << Request(request);
    std::string response;
    if (request == "Hello")
    {
        response = "Hi, " + user;
    }
    else
    {
        response = "Unrecognized request.";
    }
    MereMemo::log(debug, User(user), LogPath(path))
        << "Sending: " << Response(response);
    return response;
}

一些关于 TDD 的书籍和指南会说,对于一个初始测试来说,代码太多,不应该有任何日志记录或检查request字符串,实际上,第一个实现应该返回一个空字符串,以便测试失败。

然后,响应应该硬编码为测试期望的确切值。然后应该创建另一个使用不同user ID 的测试。只有在这种情况下,response才应该通过查看传递给handleRequest方法的user ID 来构建。

在创建更多通过不同request字符串的测试之后,检查request与已知值应该放在后面。我相信你明白了这个意思。

虽然我喜欢遵循步骤,但我认为更倾向于编写一些额外的代码,这样 TDD 过程就不会过于繁琐。这个初始服务仍然做得很少。添加日志和一些初始结构到代码有助于为后续内容打下基础。至少这是我的观点。

对于日志记录,你会在log调用中注意到一些像User(user)这样的内容。这些是自定义日志标签,就像我们在第十章中构建的标签一样,深入理解 TDD 过程。所有自定义标签都在最后一个项目文件LogTags.h中定义,其内容如下:

#ifndef SIMPLESERVICE_LOGTAGS_H
#define SIMPLESERVICE_LOGTAGS_H
#include <MereMemo/Log.h>
namespace SimpleService
{
inline MereMemo::LogLevel error("error");
inline MereMemo::LogLevel info("info");
inline MereMemo::LogLevel debug("debug");
class User : public MereMemo::StringTagType<User>
{
public:
    static constexpr char key[] = "user";
    User (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class LogPath : public MereMemo::StringTagType<LogPath>
{
public:
    static constexpr char key[] = "logpath";
    LogPath (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class Request : public MereMemo::StringTagType<Request>
{
public:
    static constexpr char key[] = "request";
    Request (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
class Response : public MereMemo::StringTagType<Response>
{
public:
    static constexpr char key[] = "response";
    Response (std::string const & value,
        MereMemo::TagOperation operation =
            MereMemo::TagOperation::None)
    : StringTagType(value, operation)
    { }
};
} // namespace SimpleService
#endif // SIMPLESERVICE_LOGTAGS_H

此文件定义了自定义的UserLogPathRequestResponse标签。命名的日志级别errorinfodebug也被定义。所有标签都放置在Service类相同的SimpleService命名空间中。

注意,日志项目还包括一个名为LogTags.h的文件,它被放在tests文件夹中,因为我们正在测试日志本身。对于这个服务项目,LogTags.h文件在服务文件夹中,因为标签是服务的一部分。我们不再测试标签是否工作。我们甚至不再测试日志是否工作。标签作为正常服务操作的一部分被记录,因此它们现在是服务项目的一部分。

一切准备就绪后,我们可以构建和运行测试项目,这表明单个测试通过。总结报告实际上显示由于设置和拆卸,有三个测试通过。报告看起来像这样:

Running 1 test suites
--------------- Suite: Service 1
------- Setup: Service 1
Passed
------- Test: Message can be sent and received
Passed
------- Teardown: Service 1
Passed
-----------------------------------
Tests passed: 3
Tests failed: 0

我们还可以查看包含这些消息的日志文件:

2022-08-14T05:58:13.543 log_level="info" Service is starting.
2022-08-14T05:58:13.545 log_level="debug" logpath="" user="123" Received: request="Hello"
2022-08-14T05:58:13.545 log_level="debug" logpath="" user="123" Sending: response="Hi, 123"

现在,我们可以看到构成服务的核心结构。服务首先启动并准备好处理请求。当一个请求到达时,请求被记录,进行处理以生成响应,然后记录响应,再将其发送回调用者。

我们使用测试库通过跳过所有网络和路由,直接到服务以启动服务并处理请求来模拟真实服务。

我们现在不会添加更多测试。但对你自己的服务项目来说,那将是你的下一步。如果你的服务支持多种不同的请求,你将为每种请求类型添加一个测试。别忘了添加一个未识别请求的测试。

每种请求类型可能对不同组合的请求参数有多个测试。记住,在真实服务中的一个真实请求将有能力定义丰富和复杂的请求,其中请求可以指定自己的参数集,就像函数可以定义自己的参数一样。

每种请求类型通常都有自己的响应类型。你可能有一个用于错误的通用响应类型。或者,每种响应类型都需要包含错误信息的字段。如果你的响应类型用于成功响应,并且任何错误响应都返回你定义的标准错误响应类型,这可能更容易。

在测试服务时,另一个好主意是为每种请求类型创建一个日志标签。我们只有一个问候请求,但想象一下一个可以处理多种不同请求的服务。如果每个日志消息都标记了请求类型,那么仅启用一种类型的请求的调试日志就变得容易了。

目前,我们正在使用用户 ID 标记日志消息。这是在不使日志文件因过多的日志消息而溢出的情况下启用调试级别日志的另一种绝佳方法。我们可以设置一个过滤器来为特定的测试用户 ID 记录调试日志条目。我们还需要设置一个默认过滤器为info。然后我们可以将用户 ID 与请求类型结合起来,以获得更精确的结果。一旦设置好过滤器,正常请求将以 info 级别记录,而测试用户将针对特定请求类型记录所有内容。

摘要

提供写作服务需要大量的支持代码和网络连接,而这些是单体应用所不需要的。服务的部署和管理也更加复杂。那么,为什么有人会设计一个使用服务而不是将所有内容都放入单一单体应用中的解决方案呢?因为服务可以帮助简化应用程序的设计,尤其是对于非常大的应用程序。而且因为服务运行在分布式计算机上,你可以扩展解决方案并提高可靠性。使用服务发布更改和新功能也变得更加容易,因为每个服务都可以独立进行测试和更新。你不必测试一个巨大的应用程序并一次性发布所有内容。

本章探讨了服务的一些不同测试挑战以及可以测试的内容。你被介绍了一个简单的服务,它跳过了路由和网络连接,直接进入服务的核心:启动服务并处理请求的能力。

本章开发的一个简单服务将测试和日志库结合起来,这两个库都在服务中使用。在设计你自己的需要使用这两个库的项目时,你可以遵循类似的项目结构。

下一章将探讨在测试中使用多个线程的困难。我们将测试日志库以确保它是线程安全的,了解线程安全意味着什么,并探讨如何使用多个线程测试服务。

第十五章:如何使用多个线程进行测试

多线程是编写软件中最困难的部分之一。常常被忽视的是我们如何测试多个线程。我们能否使用 TDD 来帮助设计使用多个线程的软件?是的,TDD 可以帮助,你将在本章中找到有用的实用指导,它将向你展示如何使用 TDD 与多个线程一起工作。

本章的主要内容包括以下几项:

  • 在测试中使用多个线程

  • 使日志库线程安全

  • 需要证明多线程的必要性

  • 改变服务的返回类型

  • 进行多次服务调用

  • 如何在不使用睡眠的情况下测试多个线程

  • 修复检测到的最后一个日志问题

首先,我们将检查在使用测试中的多个线程时你会遇到什么问题。你将学习如何使用测试库中的一个特殊辅助类来简化测试多个线程时所需的额外步骤。

一旦我们能在测试中使用多个线程,我们就会利用这个能力同时从多个线程调用日志库并观察会发生什么。我会给你一个提示:需要对日志库进行一些修改,以便在从多个线程调用时库能表现得更好。

然后,我们将回到上一章中开发的简单服务,你将学习如何使用 TDD 设计一个使用多个线程的服务,这样就可以支持可靠的测试。

在本章中,我们将依次处理每个项目。首先,我们将使用测试库项目。然后,我们将切换到日志库项目。最后,我们将使用简单服务项目。

技术要求

本章中的所有代码都使用标准 C++,它基于任何现代 C++ 20 或更高版本的编译器和标准库。本章中的代码使用了本书中开发的三个项目:来自第一部分的测试库Testing MVP,来自第二部分的日志库Logging Library,以及上一章中的简单服务。

你可以在这本书的 GitHub 仓库中找到本章的所有代码:github.com/PacktPublishing/Test-Driven-Development-with-CPP

在测试中使用多个线程

在你的测试中添加多个线程带来的挑战,你需要意识到。我说的不是在多个线程中运行测试本身。测试库注册并运行测试,它将保持单线程。你需要理解的是在测试内部创建多个线程时可能出现的各种问题。

为了理解这些问题,让我们创建一个使用多个线程的测试,这样你就可以确切地看到会发生什么。在本节中,我们将与单元测试库项目一起工作,因此,首先添加一个名为Thread.cpp的新测试文件。在你添加了新文件后,项目结构应该看起来像这样:

MereTDD project root folder
    Test.h
    tests folder
        main.cpp
        Confirm.cpp
        Creation.cpp
        Hamcrest.cpp
        Setup.cpp
        Thread.cpp

Thread.cpp文件中,添加以下代码:

#include "../Test.h"
#include <atomic>
#include <thread>
using namespace MereTDD;
TEST("Test can use additional threads")
{
    std::atomic<int> count {0};
    std::thread t1([&count]()
    {
        for (int i = 0; i < 100'000; ++i)
        {
            ++count;
        }
        CONFIRM_THAT(count, NotEquals(100'001));
    });
    std::thread t2([&count]()
    {
        for (int i = 0; i < 100'000; ++i)
        {
            --count;
        }
        CONFIRM_THAT(count, NotEquals(-100'001));
    });
    t1.join();
    t2.join();
    CONFIRM_THAT(count, Equals(0));
}

上述代码包括atomic,这样我们就可以安全地从多个线程修改count变量。我们需要包含thread来引入线程类的定义。测试创建了两个线程。第一个线程增加count,而第二个线程减少相同的count。最终结果应该将count返回到零,因为我们增加和减少的次数相同。

如果你构建并运行测试应用程序,一切都会通过。新的测试根本不会造成任何问题。让我们更改第三个CONFIRM_THAT宏,以便我们可以尝试在测试结束时确认count不等于0,如下所示:

    t1.join();
    t2.join();
    CONFIRM_THAT(count, NotEquals(0));

这次更改导致测试失败,结果如下:

------- Test: Test can use additional threads
Failed confirm on line 30
    Expected: not 0
    Actual  : 0

到目前为止,我们有一个使用多个线程的测试,它按预期工作。我们添加了一些确认,可以检测并报告当值不匹配预期值时的情况。你可能会想知道当线程似乎到目前为止都在正常工作时,多线程可能会引起什么问题。

这是个快速回答:在测试中创建一个或多个线程根本不会造成任何问题——也就是说,假设线程被正确管理,例如确保在测试结束时它们被连接。确认从主测试线程本身按预期工作。你甚至可以在附加线程中进行确认。当附加线程中的一个确认失败时,会出现一种问题。为了看到这一点,让我们将最终的确认放回Equals,并将第一个确认也改为Equals,如下所示:

        for (int i = 0; i < 100'000; ++i)
        {
            ++count;
        }
        CONFIRM_THAT(count, Equals(100'001));

count永远不会达到100'001,因为我们只增加100'000次。在这次更改之前,确认总是通过,这就是为什么它没有引起问题的原因。但是,这次更改后,确认会立即失败。如果这是一个主测试线程中的确认,那么失败会导致测试失败,并带有描述问题的总结消息。但现在我们不在主测试线程中。

记住,失败的确认会抛出异常,并且线程内部未处理的异常会导致应用程序终止。当我们确认计数等于100'001时,我们导致抛出异常。主要的测试线程由测试库管理,主线程准备好捕获任何确认异常以便报告。然而,测试 lambda 内部的附加线程没有针对抛出异常的保护。因此,当我们构建和运行测试应用程序时,它会像这样终止:

------- Test: Test can use additional threads
terminate called after throwing an instance of 'MereTDD::ActualConfirmException'
Abort trap: 6

根据你使用的计算机不同,你可能会得到一条稍微不同的消息。你不会得到的是运行并报告所有测试结果的应用程序。当附加线程中的确认失败并抛出异常时,应用程序很快就会终止。

除了线程内部确认失败并抛出异常之外,在测试中使用多个线程还有其他问题吗?是的。线程需要被正确管理——也就是说,我们需要确保它们在超出作用域之前要么被连接,要么被分离。你不太可能需要在测试中创建的线程上进行分离,所以你只剩下确保在测试结束时所有在测试中创建的线程都被连接。请注意,我们正在使用的测试手动连接了两个线程。

如果测试有其他确认,那么你需要确保失败的确认不会导致测试跳过线程连接。这是因为留下未连接的测试也会导致应用程序终止。让我们通过将第一个确认放回使用NotEquals来避免任何问题,这样它就不会引起任何问题。然后,我们将添加一个新的确认,它将在连接之前失败:

    CONFIRM_TRUE(false);
    t1.join();
    t2.join();
    CONFIRM_THAT(count, Equals(0));

额外线程内的确认不再引起任何问题。然而,新的CONFIRM_TRUE确认将导致跳过连接。结果是另一种终止:

------- Test: Test can use additional threads
terminate called without an active exception
Abort trap: 6

我们不会做任何事情来帮助解决这种第二种类型的终止问题。你需要确保所有创建的线程都被正确连接。你可能想使用 C++20 中的新功能jthread,这将确保线程被连接。或者,你可能只需要小心地将确认放在主测试线程中的位置,以确保所有连接都首先发生。

我们现在可以移除CONFIRM_TRUE确认,这样我们就可以专注于修复线程内部确认失败的第一个问题。

我们能做些什么来解决这个问题?我们可以在线程中放置一个 try/catch 块,这至少可以停止终止:

TEST("Test can use additional threads")
{
    std::atomic<int> count {0};
    std::thread t([&count]()
    {
        try
        {
            for (int i = 0; i < 100'000; ++i)
            {
                ++count;
            }
            CONFIRM_THAT(count, NotEquals(100'001));
        }
        catch (...)
        { }
    });
    t.join();
    CONFIRM_THAT(count, Equals(100'000));
}

为了简化代码,我移除了第二个线程。现在测试使用一个额外的线程来增加计数。线程完成后,count应该等于100'000。在任何时候,count都不应该达到100'001,这在线程内部得到了确认。假设我们改变线程内的确认,使其失败:

            CONFIRM_THAT(count, Equals(100'001));

在这里,异常被捕获,测试正常失败并报告结果。或者不是吗?构建和运行此代码显示所有测试都通过了。线程内的确认检测到不匹配的值,但异常没有方法报告回主测试线程。我们无法在 catch 块中抛出任何内容,因为这只会再次终止应用程序。

我们知道,通过捕获确认异常,我们可以避免测试应用程序终止。而且,我们从第一次线程测试中得知,没有抛出异常的确认也是可以的。我们需要解决的大问题是,如何让主测试线程知道任何已创建的附加线程中的确认失败情况。也许我们可以通过传递给线程的变量在捕获块中通知主线程。

我想强调这一点。如果你在测试中创建线程只是为了分割工作并加快测试速度,而且不需要在线程内进行确认,那么你不需要做任何特殊的事情。你所需要管理的只是正常的线程问题,例如确保在测试结束时连接所有线程,并且没有线程有未处理的异常。唯一需要使用以下指导的原因是当你想在附加线程中放置确认时。

在尝试了几个替代方案后,我提出了以下方案:

TEST("Test can use additional threads")
{
    ThreadConfirmException threadEx;
    std::atomic<int> count {0};
    std::thread t([&threadEx, &count]()
    {
        try
        {
            for (int i = 0; i < 100'000; ++i)
            {
                ++count;
            }
            CONFIRM_THAT(count, Equals(100'001));
        }
        catch (ConfirmException const & ex)
        {
            threadEx.setFailure(ex.line(), ex.reason());
        }
    });
    t.join();
    threadEx.checkFailure();
    CONFIRM_THAT(count, Equals(100'000));
}

这是 TDD 风格。修改测试,直到你对代码满意,然后让它工作。测试假设有一个新的异常类型叫做ThreadConfirmException,并创建了一个名为threadEx的本地实例。threadEx变量通过引用在线程 lambda 中被捕获,以便线程可以访问threadEx

线程可以使用它想要的任何正常确认,只要一切都在一个带有捕获异常ConfirmException类型的 try 块中。如果确认失败,它将抛出一个异常,该异常将被捕获。我们可以使用行号和原因在threadEx变量中设置一个失败模式。

一旦线程完成并且我们回到了主线程,我们可以调用另一个方法来检查threadEx变量中的失败情况。如果设置了失败,那么checkFailure方法应该抛出异常,就像常规确认抛出异常一样。因为我们回到了主测试线程,所以任何抛出的确认异常都将被检测并在测试总结报告中报告。

现在,我们需要在Test.h中实现ThreadConfirmException类,它可以直接放在ConfirmException基类之后,如下所示:

class ThreadConfirmException : public ConfirmException
{
public:
    ThreadConfirmException ()
    : ConfirmException(0)
    { }
    void setFailure (int line, std::string_view reason)
    {
        mLine = line;
        mReason = reason;
    }
    void checkFailure () const
    {
        if (mLine != 0)
        {
            throw *this;
        }
    }
};

如果我们现在构建并运行,那么线程内的确认将检测到count不等于100'001,失败将在总结结果中报告,如下所示:

------- Test: Test can use additional threads
Failed confirm on line 20
    Expected: 100001
    Actual  : 100000

现在的问题是,是否有任何方法可以简化测试?当前的测试看起来是这样的:

TEST("Test can use additional threads")
{
    ThreadConfirmException threadEx;
    std::atomic<int> count {0};
    std::thread t([&threadEx, &count]()
    {
        try
        {
            for (int i = 0; i < 100'000; ++i)
            {
                ++count;
            }
            CONFIRM_THAT(count, Equals(100'001));
        }
        catch (ConfirmException const & ex)
        {
            threadEx.setFailure(ex.line(), ex.reason());
        }
    });
    t.join();
    threadEx.checkFailure();
    CONFIRM_THAT(count, Equals(100'000));
}

这里,我们有一个新的ThreadConfirmException类型,这是好的。然而,测试作者仍然需要将此类型的实例传递给线程函数,类似于threadEx被 lambda 捕获的方式。线程函数仍然需要一个 try/catch 块,并在捕获到异常时调用setFailure。最后,测试需要在回到主测试线程后检查失败。所有这些步骤都在测试中展示。

我们可能可以使用一些宏来隐藏 try/catch 块,但这看起来很脆弱。测试作者可能会有一些不同的需求。例如,让我们回到两个线程,看看多线程的测试会是什么样子。改变测试,使其看起来像这样:

TEST("Test can use additional threads")
{
    std::vector<ThreadConfirmException> threadExs(2);
    std::atomic<int> count {0};
    std::vector<std::thread> threads;
    for (int c = 0; c < 2; ++c)
    {
        threads.emplace_back(
            [&threadEx = threadExs[c], &count]()
        {
            try
            {
                for (int i = 0; i < 100'000; ++i)
                {
                    ++count;
                }
                CONFIRM_THAT(count, Equals(200'001));
            }
            catch (ConfirmException const & ex)
            {
                threadEx.setFailure(ex.line(), ex.reason());
            }
        });
    }
    for (auto & t : threads)
    {
        t.join();
    }
    for (auto const & ex: threadExs)
    {
        ex.checkFailure();
    }
    CONFIRM_THAT(count, Equals(200'000));
}

这个测试与该节开头原始的两个线程测试不同。我以不同的方式编写了这个测试,以展示编写多线程测试有很多种方法。因为我们线程内部有更多的代码来处理确认异常,所以我让每个线程都相似。不再是其中一个线程增加计数,而另一个线程减少,现在两个线程都增加。此外,不再为每个线程命名 t1t2,新的测试将线程放入一个向量中。我们还有一个 ThreadConfirmException 向量,每个线程都获得对其自己的 ThreadConfirmException 的引用。

关于这个解决方案需要注意的一点是,虽然每个线程都会失败其确认,并且两个 ThreadConfirmationException 实例都将有一个失败集,但只会报告一个失败。在测试末尾的循环中,通过 threadExs 集合,一旦一个 ThreadConfirmationException 失败检查,就会抛出异常。我曾考虑扩展测试库以支持多个失败,但最终决定不增加复杂性。

如果你有一个多线程的测试,那么它们可能会使用不同的数据集。如果恰好发生错误导致多个线程在同一测试运行中失败,那么测试应用程序中只会报告一个失败。修复该失败并再次运行可能会报告下一个失败。逐个修复问题虽然有些繁琐,但不太可能需要增加测试库的复杂性。

新的具有两个线程的测试结构突出了创建可以隐藏所有线程确认处理的合理宏的难度。到目前为止,测试的三个版本都不同。似乎没有一种编写多线程测试的通用方法,我们可以将其封装在某个宏中。我认为我们将坚持我们现在所拥有的——一个可以传递给线程的 ThreadConfirmException 类型。线程需要捕获 ConfirmException 类型并调用 setFailure。主测试线程然后可以检查每个 ThreadConfirmException,如果设置了失败,它将抛出异常。在我们继续之前,让我们改变线程 lambda 中的确认,使其测试计数不等于 200'001,如下所示:

                CONFIRM_THAT(count, NotEquals(200'001));

NotEquals 确认将允许测试再次通过。

通过本节获得的理解,您将能够编写在测试中使用多个线程的测试。您可以继续使用相同的CONFIRMCONFIRM_THAT宏来验证结果。下一节将使用多个线程来记录消息,以确保日志库是线程安全的。您还将了解代码线程安全意味着什么。

使日志库线程安全

我们不知道使用日志库的项目是会尝试从多个线程或单个线程进行日志记录。在使用应用程序时,我们完全控制,可以选择使用多个线程或不使用。但是,库,尤其是日志库,通常需要是线程安全的。这意味着当应用程序从多个线程使用库时,日志库需要表现良好。使代码线程安全会给代码增加一些额外的开销,如果库只会在单个线程中使用,则不需要这样做。

我们需要的是一个同时从多个线程调用log的测试。让我们使用我们现在的代码编写一个测试,看看会发生什么。在本节中,我们将使用日志项目,并在tests文件夹中添加一个名为Thread.cpp的新文件。添加新文件后的项目结构将如下所示:

MereMemo project root folder
    MereTDD folder
        Test.h
    MereMemo folder
        Log.h
        tests folder
            main.cpp
            Construction.cpp
            LogTags.h
            Tags.cpp
            Thread.cpp
            Util.cpp
            Util.h

Thread.cpp文件内部,让我们添加一个测试,从多个线程调用log函数,如下所示:

#include "../Log.h"
#include "Util.h"
#include <MereTDD/Test.h>
#include <thread>
TEST("log can be called from multiple threads")
{
    // We'll have 3 threads with 50 messages each.
    std::vector<std::string> messages;
    for (int i = 0; i < 150; ++i)
    {
        std::string message = std::to_string(i);
        message += " thread-safe message ";
        message += Util::randomString();
        messages.push_back(message);
    }
    std::vector<std::thread> threads;
    for (int c = 0; c < 3; ++c)
    {
        threads.emplace_back(
            [c, &messages]()
        {
            int indexStart = c * 50;
            for (int i = 0; i < 50; ++i)
            {
                MereMemo::log() << messages[indexStart + i];
            }
        });
    }
    for (auto & t : threads)
    {
        t.join();
    }
    for (auto const & message: messages)
    {
        bool result = Util::isTextInFile(message,              "application.log");
        CONFIRM_TRUE(result);
    }
}

此测试执行三项操作。首先,它创建150条消息。我们将在启动线程之前准备好消息,这样线程就可以尽可能快地多次在循环中调用log

一旦消息准备好,测试将启动3个线程,每个线程将记录已经格式化的部分消息。第一个线程将记录消息049。第二个线程将记录消息5099。最后,第三个线程将记录消息100149。我们在线程中不做任何确认。

一旦所有消息都已记录,并且线程已合并,测试将确认所有150条消息都出现在日志文件中。

构建和运行此测试几乎肯定会失败。这种类型的测试违反了第八章中解释的良好的测试的一个要点,即什么是好的测试?。这种测试不是最好的类型,因为测试不是完全可重复的。每次运行测试应用程序时,您都会得到一个略有不同的结果。您甚至可能会发现这个测试会导致其他测试失败!

尽管我们不是基于随机数来构建测试的行为,但我们使用了线程。线程调度是不可预测的。使这个测试大部分可靠的方法是记录许多消息,就像我们已经在做的那样。测试会尽其所能设置线程以产生冲突。这就是为什么消息是预格式化的。我希望线程立即进入记录消息的循环,而不是花费额外的时间来格式化消息。

当测试失败时,是因为日志文件混乱。我的一个测试运行中日志文件的一部分看起来像这样:

2022-08-16T04:54:54.635 100 thread-safe message 4049
2022-08-16T04:54:54.635 100 thread-safe message 4049
2022-08-16T04:54:54.635 0 thread-safe message 8866
2022-08-16T04:54:54.637 101 thread-safe message 8271
2022-08-16T04:54:54.637 1 thread-safe message 3205
2022-08-16T04:54:54.637 102 thread-safe message 7514
2022-08-16T04:54:54.637 51 thread-safe message 7405
2022-08-16T04:54:54.637 2 thread-safe message 5723
2022-08-16T04:54:54.637 52 thread-safe message 4468
2022-08-16T04:54:54.637 52 thread-safe message 4468

我移除了colorlog_level标签,以便你能更好地看到消息。你首先会注意到一些消息是重复的。编号100出现了两次,而编号50似乎完全缺失。

说实话,我本以为日志文件会比现在更混乱。消息组0-4950-99以及100-149之间的交错是预期的。我们确实有三个线程同时运行。例如,一旦消息编号51被记录,我们应该已经看到了编号50

让我们修复日志代码,以便测试通过。这仍然不会是最好的测试,但如果日志库不是线程安全的,它将有很大的机会找到错误。

修复很简单:我们需要一个互斥锁,然后我们需要锁定互斥锁。首先,让我们在Log.h的顶部包含mutex标准头文件,如下所示:

#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <memory>
#include <mutex>
#include <ostream>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

然后,我们需要一个地方放置全局互斥锁。由于日志库是一个单头文件,我们无法声明全局变量而不产生链接错误。我们可能能够将全局互斥锁声明为内联。这是 C++中的一个新特性,它允许你声明内联变量,就像我们可以声明内联函数一样。我更习惯于使用静态变量的函数。将以下函数添加到Log.h的顶部,紧接在MereMemo命名空间的开头之后:

inline std::mutex & getLoggingMutex ()
{
    static std::mutex m;
    return m;
}

现在,我们需要在适当的位置锁定互斥锁。起初,我在log函数中添加了锁定,但没有任何效果。这是因为log函数在没有实际记录的情况下返回一个LogStream。所以,log函数在记录发生之前获得了锁并释放了锁。记录是在LogStream析构函数中完成的,所以我们需要在那里放置锁:

    ~LogStream ()
    {
        if (not mProceed)
        {
            return;
        }
        const std::lock_guard<std::mutex>               lock(getLoggingMutex());
        auto & outputs = getOutputs();
        for (auto const & output: outputs)
        {
            output->sendLine(this->str());
        }
    }

锁尝试获取互斥锁,如果另一个线程已经拥有互斥锁,则会阻塞。一次只有一个线程可以在锁定之后进行,并且在将文本发送到所有输出之后释放锁。

如果我们构建并运行,线程问题将会得到解决。然而,当我运行测试应用程序时,有一个测试失败了。起初,我以为线程仍然存在问题,但失败发生在另一个测试上。这是失败的测试:

TEST("Overridden default tag not used to filter messages")
{
    MereTDD::SetupAndTeardown<TempFilterClause> filter;
    MereMemo::addFilterLiteral(filter.id(), info);
    std::string message = "message ";
    message += Util::randomString();
    MereMemo::log(debug) << message;
    bool result = Util::isTextInFile(message,          "application.log");
    CONFIRM_FALSE(result);
}

这个测试与多线程测试无关。那么,为什么它失败了?嗯,问题在于这个测试正在确认一个特定的消息不会出现在日志文件中。但是这个消息只是单词 "message",后面跟着一个随机数字字符串。我们刚刚添加了额外的 150 条日志消息,这些消息都有相同的文本,后面跟着一个随机数字字符串。

我们自身在测试上遇到了问题。测试有时会因为随机数而失败。在我们只有少量日志消息时,这个问题并没有被发现,但现在我们有更多机会出现重复的随机数,这个问题就更加明显了。

我们可以增加添加到每个日志消息中的随机数字字符串的大小,或者使测试更加具体,以便它们都使用不同的基消息字符串。

到目前为止,你可能想知道为什么我的测试有一个简单的基消息,而自从日志库首次创建以来,我们一直在每个测试中使用独特的消息第九章使用测试。那是因为从第九章使用测试开始的代码原本就有简单、通用的日志消息。我本可以将这些通用消息保持原样,并等到现在让你回过头来更改它们。然而,我编辑了这些章节,从开始就解决了问题。现在只是为了更改一个字符串而通过所有测试似乎是一种浪费。因此,我在第九章使用测试中添加了说明。现在我们不需要更改任何测试消息,因为它们已经被修复了。

好的,回到多线程的话题——新的测试现在通过了,日志文件中的样本看起来好多了:

2022-08-16T06:20:36.807 0 thread-safe message 6269
2022-08-16T06:20:36.807 50 thread-safe message 1809
2022-08-16T06:20:36.807 100 thread-safe message 6297
2022-08-16T06:20:36.808 1 thread-safe message 848
2022-08-16T06:20:36.808 51 thread-safe message 4103
2022-08-16T06:20:36.808 101 thread-safe message 5570
2022-08-16T06:20:36.808 2 thread-safe message 6156
2022-08-16T06:20:36.809 102 thread-safe message 4213
2022-08-16T06:20:36.809 3 thread-safe message 6646

再次强调,这个样本已经被修改,以删除colorlog_level标签。这个更改使得每一行都更短,这样你可以更好地看到消息。每个线程中的消息是有序的,即使消息在线程之间混合——也就是说,消息编号0在某个时候会被消息编号1跟随,然后是编号2;消息编号50稍后被编号51跟随,消息编号100被编号101跟随。每个后续编号的消息可能不会立即跟随前一个消息。这个样本看起来更好,因为没有重复的消息,也没有缺失的消息。

最后一个想法是关于日志库的线程安全性。我们测试了多个线程可以安全地调用log而不必担心问题。但我们没有测试多个线程是否可以管理默认标签或过滤,或者添加新的输出。日志库可能需要更多的工作才能完全线程安全。现在它对我们的目的来说已经足够了。

现在日志库基本上是线程安全的,下一节将回到SimpleService项目,开始探索如何测试使用多线程的代码。

需要证明多线程的必要性

到目前为止,在本章中,你已经学习了如何编写使用多线程的测试,以及如何使用这些额外的线程来测试日志库。日志库本身并不使用多线程,但我们需要确保日志库在使用多线程时是安全的。

本章的剩余部分将提供一些关于如何测试使用多线程的代码的指导。为了测试多线程代码,我们需要一些使用多线程的代码。为此,我们将使用上一章的SimpleService项目。

我们需要修改简单的服务,使其使用多个线程。目前,简单服务是一个问候服务的例子,它根据请求问候的用户进行回复。在问候服务中并不需要太多多线程。我们需要一些不同的东西。

这引出了第一条指导原则:在我们尝试添加多个线程之前,我们需要确保存在一个有效的多线程需求。编写多线程代码很困难,如果只需要一个线程,则应避免使用多线程。如果你只需要一个线程,那么确保遵循上一节的建议,并在代码将被多个线程使用时使其线程安全。

你的目标是尽可能多地编写单线程的代码。如果你能找到一个特定的计算结果的方法,它只需要一些输入数据来得到输出,那么尽可能将其作为单线程计算。如果输入数据量很大,并且可以分割成单独计算的部分,那么将输入分割并传递更小的数据块给计算。保持计算单线程,并专注于处理提供的输入。然后,你可以创建多个线程,每个线程被分配一部分输入数据来计算。这将使你的多线程代码与计算分离。

将你的单线程代码隔离出来,将允许你在无需担心线程管理的情况下设计和测试代码。当然,你可能需要确保代码是线程安全的,但当你只需要担心线程安全时,这会更容易。

由于线程调度的随机性,测试多线程更困难。如果可能,尽量避免使用诸如睡眠之类的笨拙方法来协调测试。你想要避免将实际的代码线程置于睡眠状态以协调线程之间的顺序。当一个线程进入睡眠状态时,它会停止运行一段时间,具体取决于在睡眠调用中指定的延迟时间。其他未睡眠的线程可以被调度运行。

我们将在本章设计的代码将允许测试控制线程的同步,这样我们就可以去除随机性并使测试可预测。我们不妨从修改后的服务开始,这个服务有使用多个线程的理由。修改后的handleRequest方法如下所示:

std::string SimpleService::Service::handleRequest (
    std::string const & user,
    std::string const & path,
    std::string const & request)
{
    MereMemo::log(debug, User(user), LogPath(path))
        << "Received: " << Request(request);
    std::string response;
    if (request == "Calculate")
    {
        response = "token";
    }
    if (request == "Status")
    {
        response = "result";
    }
    else
    {
        response = "Unrecognized request.";
    }
    MereMemo::log(debug, User(user), LogPath(path))
        << "Sending: " << Response(response);
    return response;
}

在遵循 TDD(测试驱动开发)时,你通常会先从测试开始。那么,为什么我先向你展示一个修改后的服务呢?因为我们的目标是测试多线程代码。在你的项目中,你应该避免在没有充分理由的情况下使用某些技术。我们的理由是需要一个可以学习的例子。因此,我们是从反向需求开始使用多线程的。

我试图想出一个问候服务使用多个线程的好理由,但想不出来。所以,我们将服务改为稍微复杂一些的东西;在我们开始编写测试之前,我想解释这个新想法。

新服务仍然尽可能简单。我们将继续忽略所有网络和消息路由。我们需要将请求和响应类型更改为结构体,并且我们还将继续忽略将数据结构序列化以传输到和从服务中。

新服务将模拟一个难题的计算。创建新线程的一个有效理由是让新线程执行一些工作,而原始线程继续它正在做的事情。新服务的设计理念是Calculate请求可能需要很长时间才能完成,我们不希望调用者在等待结果时超时。因此,服务将创建一个新线程来执行计算,并立即向调用者返回一个令牌。调用者可以使用这个令牌以不同的Status请求回调到服务,这将检查刚刚开始的计算进度。如果计算尚未完成,则Status请求的响应将让调用者知道大约完成了多少。如果计算已完成,则响应将包含答案。

我们现在有理由使用多个线程并可以编写一些测试。让我们处理一个本应已经添加的无关测试。我们想要确保任何使用未识别请求调用服务的人都会收到一个未识别的响应。将以下测试放入SimpleService项目的tests文件夹中的Message.cpp文件中:

TEST_SUITE("Unrecognized request is handled properly", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Hello";
    std::string expectedResponse = "Unrecognized request.";
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, Equals(expectedResponse));
}

我将这个测试放在Message.cpp的顶部。它所做的只是发送之前的问候请求,但期望得到一个未识别的响应。

让我们还在SetupTeardown.cpp中将测试套件的名称更改为"Calculation Service",如下所示:

MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1("Calculation Service", "Service 1");

现在,让我们删除问候测试并添加以下简单测试,以确保我们得到除未识别响应之外的其他响应:

TEST_SUITE("Calculate request can be sent and recognized", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    std::string request = "Calculate";
    std::string unexpectedResponse = "Unrecognized request.";
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, NotEquals(unexpectedResponse));
}

这个测试与未识别测试相反,确保响应不是未识别的。通常,确认结果符合预期发生的事情,而不是确认结果不是你不想发生的事情,会更好。双重否定不仅更难思考,而且可能导致问题,因为不可能捕捉到所有可能出错的方式。通过确认你想要发生的事情,你可以消除所有可能的错误条件,这些条件太多,无法单独捕捉。

这个测试有一点不同。我们并不关心响应。这个测试的目的是确认请求已被识别。确认响应不是未识别的,即使这看起来与刚刚描述的双重否定陷阱相似,也是合适的。

构建和运行此代码显示,未识别的测试通过了,但Calculate请求失败了:

Running 1 test suites
--------------- Suite: Service 1
------- Setup: Calculation Service
Passed
------- Test: Unrecognized request is handled properly
Passed
------- Test: Calculate request can be sent and recognized
Failed confirm on line 30
    Expected: not Unrecognized request.
    Actual  : Unrecognized request.
------- Teardown: Calculation Service
Passed
-----------------------------------
Tests passed: 3
Tests failed: 1

看起来,对于应该有效的请求,我们得到了一个未识别的响应。这就是在项目开始时添加简单测试的价值所在。测试有助于立即捕捉到简单的错误。问题是出在handleRequest方法中。我通过复制第一次检查添加了第二次对有效请求的检查,却忘记了将if语句更改为else if语句。修复方法如下:

    if (request == "Calculate")
    {
        response = "token";
    }
    else if (request == "Status")
    {
        response = "result";
    }
    else
    {
        response = "Unrecognized request.";
    }

为了进一步进行,我们将发送和接收不仅仅是字符串。当我们发送一个Calculate请求时,我们应该得到一个可以传递给Status请求的令牌值。然后Status响应应该包含答案或进度估计。让我们一步一步来,定义Calculate请求和响应结构。将以下两个结构定义添加到Service.h文件中的SimpleService命名空间顶部:

struct CalculateRequest
{
    int mSeed;
};
struct CalculateResponse
{
    std::string mToken;
};

这将允许我们传递一些初始值进行计算;作为回报,我们将得到一个可以用来最终获取答案的令牌。但我们有一个问题。如果将Calculate请求更改为返回结构体,那么这将破坏现有的测试,因为测试期望得到一个字符串。我们应该改变测试,让它们使用结构体,但这又带来了另一个问题:大多数时候,我们需要返回正确的响应结构体。并且我们需要为错误情况返回错误响应。

我们需要的是可以代表良好响应和错误响应的响应。既然我们将有一个可以服务于多个目的的响应,为什么不让它也处理Status响应的结构体呢?这意味着我们将有一个单一的响应类型,它可以是一个错误响应、计算响应或状态响应。既然我们有一个多用途的响应类型,为什么不创建一个多用途的请求类型呢?让我们改变一下测试。

我们将使用 std::variant 来存储不同类型的请求和响应。我们可以移除发送了无效请求字符串的测试。我们仍然可能会收到无效请求,但这仅发生在调用者和服务之间的服务版本不匹配。这稍微复杂一些,所以我们暂时忽略服务可能对请求可用性的理解与实际服务知识不一致的情况。如果你正在编写一个真实的服务,那么这是一个需要解决和测试的可能性。你可能还想使用不同于变体的其他东西。一个好的选择可能是类似于谷歌的 Protocol Buffers,其中服务将接受 Protocol Buffer 消息。虽然使用 Protocol Buffers 比简单的结构体更好,但其设计也更加复杂,这将使解释变得更加冗长。

Message.cpp 中,我们将有一个单独的测试,其外观如下:

TEST_SUITE("Calculate request can be sent", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    SimpleService::RequestVar request =
        SimpleService::CalculateRequest {
            .mSeed = 5
        };
    std::string emptyResponse = "";
    std::string response = gService1.service().handleRequest(
        user, path, request);
    CONFIRM_THAT(response, NotEquals(emptyResponse));
}

此测试首先关注请求类型,并将响应类型留为字符串。我们将逐步进行更改。这对于使用 std::variant 尤其是当你不熟悉变体时非常有用。我们将有一个名为 RequestVar 的变体类型,它可以被初始化为特定的请求类型。我们使用 CalculateRequest 初始化请求,并使用 指定初始化器 语法设置 mSeed 值。指定初始化器语法在 C++ 中相对较新,它允许我们通过在数据成员名称前放置一个点来根据名称设置数据成员的值。

现在,让我们在 Service.h 中定义请求类型:

#ifndef SIMPLESERVICE_SERVICE_H
#define SIMPLESERVICE_SERVICE_H
#include <string>
#include <variant>
namespace SimpleService
{
struct CalculateRequest
{
    int mSeed;
};
struct StatusRequest
{
    std::string mToken;
};
using RequestVar = std::variant<
    CalculateRequest,
    StatusRequest
    >;

注意,我们需要包含标准 variant 头文件。RequestVar 类型现在只能是 CalculateRequestStatusRequest 之一。我们还需要在 Service.h 中的 Service 类的 handleRequest 方法中进行一个额外的更改:

class Service
{
public:
    void start ();
    std::string handleRequest (std::string const & user,
        std::string const & path,
        RequestVar const & request);
};

需要更改 Service.cpp 文件,以便更新 handleRequest 方法,如下所示:

std::string SimpleService::Service::handleRequest (
    std::string const & user,
    std::string const & path,
    RequestVar const & request)
{
    std::string response;
    if (auto const * req = std::get_       if<CalculateRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Calculate request for: "
            << std::to_string(req->mSeed);
        response = "token";
    }
    else if (auto const * req = std::get_            if<StatusRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Status request for: "
            << req->mToken;
        response = "result";
    }
    else
    {
        response = "Unrecognized request.";
    }
    MereMemo::log(debug, User(user), LogPath(path))
        << "Sending: " << Response(response);
    return response;
}

更新的 handleRequest 方法继续检查未知请求类型。所有响应都是字符串,需要更改。我们目前还没有查看种子或令牌值,但我们已经有了足够的内容可以构建和测试。

现在单个测试通过后,在下一节中,我们将查看响应并使用结构体而不是响应字符串。

更改服务返回类型

我们将在本节中进行类似的更改,以摆脱字符串并使用结构体来处理服务请求。上一节更改了服务请求类型;本节将更改服务返回类型。我们需要进行这些更改,以便将服务提升到能够支持额外线程需求的功能水平。

我们使用的SimpleService项目最初是一个问候服务,我无法想出任何理由说明这样一个简单的服务需要另一个线程。我们在上一节中开始将服务调整为计算服务;现在,我们需要修改服务在处理请求时返回的返回类型。

首先,让我们在Service.h中定义返回类型结构体,它紧随请求类型之后。将以下代码添加到Service.h中:

struct ErrorResponse
{
    std::string mReason;
};
struct CalculateResponse
{
    std::string mToken;
};
struct StatusResponse
{
    bool mComplete;
    int mProgress;
    int mResult;
};
using ResponseVar = std::variant<
    ErrorResponse,
    CalculateResponse,
    StatusResponse
    >;

这些结构和变体遵循与请求相同的模式。一个小差异是,我们现在有一个ErrorResponse类型,它将用于任何错误。我们可以修改Message.cpp中的测试,使其看起来像这样:

TEST_SUITE("Calculate request can be sent", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    SimpleService::RequestVar request =
        SimpleService::CalculateRequest {
            .mSeed = 5
        };
    auto const responseVar = gService1.service().handleRequest(
        user, path, request);
    auto const response =
        std::get_if<SimpleService::CalculateResponse>(&responseVar);
    CONFIRM_TRUE(response != nullptr);
}

这个测试将像之前一样调用服务,使用计算请求;返回的响应将被测试以确认它是否是计算响应。

为了使代码能够编译,我们需要更改Service.h中的handleRequest声明,使其返回新的类型,如下所示:

class Service
{
public:
    void start ();

    ResponseVar handleRequest (std::string const & user,
        std::string const & path,
        RequestVar const & request);
};

然后,我们需要更改Service.cpphandleRequest的实现:

SimpleService::ResponseVar SimpleService::Service::handleRequest (
    std::string const & user,
    std::string const & path,
    RequestVar const & request)
{
    ResponseVar response;
    if (auto const * req = std::get_       if<CalculateRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Calculate request for: "
            << std::to_string(req->mSeed);
        response = SimpleService::CalculateResponse {
            .mToken = "token"
        };
    }
    else if (auto const * req = std::get_            if<StatusRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Status request for: "
            << req->mToken;
        response = SimpleService::StatusResponse {
            .mComplete = false,
            .mProgress = 25,
            .mResult = 0
        };
    }
    else
    {
        response = SimpleService::ErrorResponse {
            .mReason = "Unrecognized request."
        };
    }
    return response;
}

代码变得越来越复杂。我在返回前移除了日志记录,之前它是用来记录响应的。我们可以把日志记录放回去,但这需要将ResponseVar转换为字符串的能力。或者,我们可以在多个地方记录响应,就像代码中对请求所做的那样。这是一个我们可以跳过的细节。

新的handleRequest方法几乎与之前所做的一样,只是现在它初始化一个ResponseVar类型而不是返回一个字符串。这允许我们在返回请求和错误时,提供比之前更详细的信息。

要添加一个测试来识别未知的请求,我们需要在RequestVar中添加一个新的请求类型,但在handleRequest方法内的if语句中忽略这个新的请求类型。我们也将跳过这个测试,因为我们真的应该使用除了std::variant之外的其他东西。

我们在这个例子中使用std::variant的唯一原因是为了避免额外的复杂性。我们试图使代码准备好支持另一个线程。

在下一节中,我们将添加一个使用两种请求类型的测试。第一个请求将开始计算,而第二个请求将在计算完成时检查计算状态并获取结果。

进行多次服务调用

如果你正在考虑使用多线程来加速计算,那么我建议你在承担多线程的额外复杂性之前,先使用单线程测试并确保代码能够正常工作。

对于我们正在工作的服务,添加第二个线程的原因不是为了提高任何东西的速度。我们需要避免一个可能需要很长时间的计算超时。我们将添加的额外线程不是为了使计算更快。一旦我们使用一个额外的线程使计算工作,我们就可以考虑添加更多线程来加快计算速度。

在原始线程继续做其他事情的同时创建一个线程来执行一些工作是常见的。这不是应该在以后进行的优化。这是设计的一部分,并且应该从一开始就包含额外的线程。

让我们从向 Message.cpp 添加一个新测试开始,这个测试看起来是这样的:

TEST_SUITE("Status request generates result", "Service 1")
{
    std::string user = "123";
    std::string path = "";
    SimpleService::RequestVar calcRequest =
        SimpleService::CalculateRequest {
            .mSeed = 5
        };
    auto responseVar = gService1.service().handleRequest(
        user, path, calcRequest);
    auto const calcResponse =
        std::get_if<SimpleService::CalculateResponse>        (&responseVar);
    CONFIRM_TRUE(calcResponse != nullptr);
    SimpleService::RequestVar statusRequest =
        SimpleService::StatusRequest {
            .mToken = calcResponse->mToken
        };
    int result {0};
    for (int i = 0; i < 5; ++i)
    {
        responseVar = gService1.service().handleRequest(
            user, path, statusRequest);
        auto const statusResponse =
            std::get_if<SimpleService::StatusResponse>            (&responseVar);
        CONFIRM_TRUE(statusResponse != nullptr);
        if (statusResponse->mComplete)
        {
            result = statusResponse->mResult;
            break;
        }
    }
    CONFIRM_THAT(result, Equals(50));
}

所有代码都已经就绪,以便这个新测试可以编译。现在,我们可以运行测试以查看会发生什么。测试将失败,如下所示:

Running 1 test suites
--------------- Suite: Service 1
------- Setup: Calculation Service
Passed
------- Test: Calculate request can be sent
Passed
------- Test: Status request generates result
Failed confirm on line 62
    Expected: 50
    Actual  : 0
------- Teardown: Calculation Service
Passed
-----------------------------------
Tests passed: 3
Tests failed: 1

这个测试做什么?首先,它创建一个计算请求 l 并获取一个硬编码的令牌值。在服务开始时还没有进行计算,所以当我们用令牌发出状态请求时,服务会响应一个硬编码的响应,表示计算尚未完成。测试正在寻找一个表示计算已完成的状态响应。测试尝试进行五次状态请求然后放弃,这导致测试结束时的确认失败,因为我们没有得到预期的结果。请注意,即使尝试多次也不是最好的做法。线程是不可预测的,你的电脑可能在服务完成请求之前就尝试了所有五次。如果你的测试继续失败,你可能需要增加尝试的次数,或者等待一段合理的时间。我们的计算最终会将种子乘以 10。所以,当我们给出初始种子 5 时,我们应该期望最终结果为 50

我们需要在服务中实现计算和状态请求处理,这样我们就可以使用一个线程来使测试通过。我们首先需要做的是在 Service.cpp 的顶部包含 mutexthreadvector。我们还需要添加一个无名的命名空间,如下所示:

#include "Service.h"
#include "LogTags.h"
#include <MereMemo/Log.h>
#include <mutex>
#include <thread>
#include <vector>
namespace
{
}

我们将需要一些锁定机制,这样我们就不在状态被线程更新时尝试读取计算状态。为了进行同步,我们将使用互斥锁和锁,就像我们在日志库中做的那样。你可能还想探索其他设计,例如为不同的计算请求分别锁定数据。我们将采用简单的方法,并为所有内容使用单个锁。在无名的命名空间内添加以下函数:

    std::mutex & getCalcMutex ()
    {
        static std::mutex m;
        return m;
    }

我们需要某种东西来跟踪每个计算请求的完成状态、进度和结果。我们将在无名的命名空间内创建一个类来保存这些信息,称为 CalcRecord,就在 getCalcMutex 函数之后,如下所示:

    class CalcRecord
    {
    public:
        CalcRecord ()
        { }
        CalcRecord (CalcRecord const & src)
        {
            const std::lock_guard<std::mutex>                   lock(getCalcMutex());
            mComplete = src.mComplete;
            mProgress = src.mProgress;
            mResult = src.mResult;
        }
        void getData (bool & complete, int & progress, int &                      result)
        {
            const std::lock_guard<std::mutex>                   lock(getCalcMutex());
            complete = mComplete;
            progress = mProgress;
            result = mResult;
        }
        void setData (bool complete, int progress, int result)
        {
            const std::lock_guard<std::mutex>                   lock(getCalcMutex());
            mComplete = complete;
            mProgress = progress;
            mResult = result;
        }
        CalcRecord &
        operator = (CalcRecord const & rhs) = delete;
    private:
        bool mComplete {false};
        int mProgress {0};
        int mResult {0};
    };

看起来这个类还有很多其他的功能,但它相当简单。默认构造函数不需要做任何事情,因为数据成员已经定义了它们的默认值。我们需要默认构造函数的唯一原因是我们还有一个拷贝构造函数。而我们需要拷贝构造函数的唯一原因是为了在复制数据成员之前锁定互斥锁。

然后,我们有一个方法可以一次性获取所有数据成员,还有一个方法可以设置数据成员。获取器和设置器在继续之前都需要获取锁。

没有必要将一个CalcRecord赋值给另一个,因此已经删除了赋值运算符。

在未命名的命名空间中,我们还需要一个CalcRecord的向量,如下所示:

    std::vector<CalcRecord> calculations;

每次有计算请求时,我们都会将一个CalcRecord添加到calculations集合中。一个真正的服务会希望清理或重用CalcRecord条目。

我们需要修改Service.cpp中的请求处理,以便每次收到计算请求时都创建一个线程来使用一个新的CalcRecord,如下所示:

    if (auto const * req = std::get_       if<CalculateRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Calculate request for: "
            << std::to_string(req->mSeed);
        calculations.emplace_back();
        int calcIndex = calculations.size() - 1;
        std::thread calcThread([calcIndex] ()
        {
            calculations[calcIndex].setData(true, 100, 50);
        });
        calcThread.detach();
        response = SimpleService::CalculateResponse {
            .mToken = std::to_string(calcIndex)
        };
    }

当我们收到一个计算请求时会发生什么?首先,我们在calculations向量的末尾添加一个新的CalcRecord。我们将使用CalcRecord的索引作为响应中返回的令牌。这是我能够想到的识别计算请求的最简单设计。一个真正的服务会希望使用一个更安全的令牌。然后,请求处理器启动一个线程来进行计算,并从线程中分离出来。

你将要编写的绝大多数线程代码都会创建一个线程然后加入该线程。创建一个线程然后从线程中分离出来并不常见。作为替代,当你想要做一些工作而不必担心加入线程时,你可以使用线程池。分离线程的原因是我想要一个最简单的例子,而不引入线程池。

线程本身非常简单,因为它立即将CalcRecord设置为完成,进度为100,结果为50

我们现在可以构建并运行测试应用程序了,但我们会得到之前相同的失败。那是因为状态请求处理仍然返回硬编码的响应。我们需要像这样修改请求处理器来处理状态请求:

    else if (auto const * req = std::get_            if<StatusRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Status request for: "
            << req->mToken;
        int calcIndex = std::stoi(req->mToken);
        bool complete;
        int progress;
        int result;
        calculations[calcIndex].getData(complete, progress,                                 result);
        response = SimpleService::StatusResponse {
            .mComplete = complete,
            .mProgress = progress,
            .mResult = result
        };
    }

通过这个更改,状态请求将令牌转换为它用来查找正确CalcRecord的索引。然后,它从CalcRecord获取当前数据,并将其作为响应返回。

你可能还想要考虑在尝试五次服务调用请求的测试循环中添加睡眠,以便给服务提供合理的时间。如果所有五次尝试都在服务完成甚至一个简单的计算之前快速完成,当前的测试将会失败。

在构建和运行测试应用程序后,所有测试都通过了。我们现在就完成了吗?还没有。所有这些更改都让服务能够在单独的线程中计算结果,同时继续在主线程上处理请求。添加另一个线程的整个目的是为了避免由于长时间计算导致的超时。但我们的计算非常快。我们需要减慢计算速度,以便我们可以以合理的响应时间测试服务。

我们如何减慢线程的运行?以及计算需要多少时间来完成?这些问题是我们在本章中编写代码来回答的。下一节将解释如何测试使用多个线程的服务。现在我们有一个使用另一个线程进行计算的服务,我们可以探索测试这种情况的最佳方法。

我还想澄清,下一节所做的是与在五个服务调用尝试中添加延迟不同。在测试循环中添加延迟将提高我们目前测试的可靠性。下一节将完全删除循环,并展示如何与其他线程协调测试,以便测试和线程可以一起进行。

如何在不使用 sleep 的情况下测试多个线程

在本章的早期,在需要证明多线程的必要性部分,我提到你应该尽量使用单线程完成尽可能多的工作。我们现在将遵循这个建议。在当前的计算请求处理中,代码创建了一个执行简单计算的线程,如下所示:

        std::thread calcThread([calcIndex] ()
        {
            calculations[calcIndex].setData(true, 100, 50);
        });

好吧,也许简单计算不是描述线程所做事情的正确方式。线程将结果设置为硬编码的值。我们知道这是临时代码,我们需要将代码更改为将种子值乘以10,这正是测试所期望的。

计算应该在何处进行?在线程 lambda 中进行计算很容易,但这将违反尽量使用单线程完成尽可能多的工作的建议。

我们想要做的是创建一个线程可以调用的计算函数。这将使我们能够单独测试计算函数,而不必担心任何线程问题,并确保计算是正确的。

这里有一个真正有趣的部分:创建一个执行计算的函数将帮助我们测试线程管理!如何?因为我们将创建两个计算函数。

一个函数将是真正的计算函数,可以独立于任何线程进行测试。对于我们的项目,真正的计算仍然简单且快速。我们不会尝试做很多工作来减慢计算,也不会让线程休眠。我们也不会编写大量测试来确保计算正确。这只是一个你可以遵循的项目模式示例。

另一个函数将是一个测试计算函数,它将执行一些旨在匹配真实计算结果的假计算。测试计算函数还将包含一些线程管理代码,用于协调线程的活动。我们将使用测试计算函数中的线程管理代码来减慢线程速度,以便模拟耗时较长的计算。

我们所做的是用代码模拟真实计算,这些代码更关注线程的行为而非计算本身。任何想要测试真实计算的测试都可以使用真实计算函数,而任何想要测试线程定时和协调的测试都可以使用测试计算函数。

首先,我们将在 Service.h 中声明这两个函数,位于 Service 类之前,如下所示:

void normalCalc (int seed, int & progress, int & result);
void testCalc (int seed, int & progress, int & result);

您可以在项目中定义自己的计算函数以执行所需的任何操作。您的函数可能不同。需要理解的主要点是它们应该具有相同的签名,以便测试函数可以替换真实函数。

Service 类需要修改,以便可以将这些函数之一注入到服务中。我们将在构造函数中设置计算函数,并使用真实函数作为默认值,如下所示:

class Service
{
public:
    using CalcFunc = void (*) (int, int &, int &);
    Service (CalcFunc f = normalCalc)
    : mCalc(f)
    { }
    void start ();
    ResponseVar handleRequest (std::string const & user,
        std::string const & path,
        RequestVar const & request);
private:
    CalcFunc mCalc;
};

Service 类现在有一个成员函数指针,它将指向其中一个计算函数。具体调用哪个函数是在创建 Service 类时确定的。

让我们按照如下方式实现这两个函数在 Service.cpp 中:

void SimpleService::normalCalc (
    int seed, int & progress, int & result)
{
    progress = 100;
    result = seed * 10;
}
void SimpleService::testCalc (
    int seed, int & progress, int & result)
{
    progress = 100;
    result = seed * 10;
}

目前,这两个函数是相同的。我们将一步一步来。每个函数只是将 progress 设置为 100,将 result 设置为 seed 乘以 10。我们将保持真实或正常函数不变。最终,我们将修改测试函数,使其控制线程。

现在,我们可以更改 Service.cpp 中的计算请求处理程序,使其使用计算函数,如下所示:

    if (auto const * req = std::get_       if<CalculateRequest>(&request))
    {
        MereMemo::log(debug, User(user), LogPath(path))
            << "Received Calculate request for: "
            << std::to_string(req->mSeed);
        calculations.emplace_back();
        int calcIndex = calculations.size() - 1;
        int seed = req->mSeed;
        std::thread calcThread([this, calcIndex, seed] ()
        {
            int progress;
            int result;
            mCalc(seed, progress, result);
            calculations[calcIndex].setData(true, progress,                                     result);
        });
        calcThread.detach();
        response = SimpleService::CalculateResponse {
            .mToken = std::to_string(calcIndex)
        };
    }

在线程 lambda 中,我们调用 mCalc 而不是将 progressresult 设置为硬编码的值。调用哪个计算函数取决于 mCalc 指向哪个函数。

如果我们构建并运行测试应用程序,我们会看到测试通过。但我们在调用 mCalc 方式上存在问题。我们希望获取中间进度,以便调用者可以发出状态请求并看到进度增加,直到计算最终完成。通过一次调用 mCalc,我们只给函数一次做事情的机会。我们应该在 progress 达到 100 百分比之前循环调用 mCalc 函数。让我们更改 lambda 代码:

        std::thread calcThread([this, calcIndex, seed] ()
        {
            int progress {0};
            int result {0};
            while (true)
            {
                mCalc(seed, progress, result);
                if (progress == 100)
                {
                    calculations[calcIndex].setData(true,                     progress, result);
                    break;
                }
                else
                {
                    calculations[calcIndex].setData(false,                     progress, result);
                }
            }
        });

此更改不会影响测试,因为当前的mCalc函数在第一次调用时将progress设置为100;因此,while 循环只会运行一次。我们不希望线程在没有与测试同步的情况下运行得太久,因为我们永远不会与线程连接。如果这是一个真实的项目,我们希望使用线程池中的线程,并在停止服务之前等待线程完成。

对测试不产生影响的更改是一种很好的验证更改的方法。采取小步骤,而不是试图在一次巨大的更改集中完成所有事情。

接下来,我们将复制生成结果的测试,但我们将使用复制测试中的测试计算函数。测试需要稍作修改,以便可以使用测试计算函数。但大部分测试应该几乎保持不变。新测试放在Message.cpp中,如下所示:

TEST_SUITE("Status request to test service generates result", "Service 2")
{
    std::string user = "123";
    std::string path = "";
    SimpleService::RequestVar calcRequest =
        SimpleService::CalculateRequest {
            .mSeed = 5
        };
    auto responseVar = gService2.service().handleRequest(
        user, path, calcRequest);
    auto const calcResponse =
        std::get_if<SimpleService::CalculateResponse>        (&responseVar);
    CONFIRM_TRUE(calcResponse != nullptr);
    SimpleService::RequestVar statusRequest =
        SimpleService::StatusRequest {
            .mToken = calcResponse->mToken
        };
    int result {0};
    for (int i = 0; i < 5; ++i)
    {
        responseVar = gService2.service().handleRequest(
            user, path, statusRequest);
        auto const statusResponse =
            std::get_if<SimpleService::StatusResponse>            (&responseVar);
        CONFIRM_TRUE(statusResponse != nullptr);
        if (statusResponse->mComplete)
        {
            result = statusResponse->mResult;
            break;
        }
    }
    CONFIRM_THAT(result, Equals(40));
}

唯一的更改是给测试一个不同的名称,以便它使用一个名为"Service 2"的新测试套件,然后使用一个不同的全局服务gService2。在这里,我们期望得到略微不同的结果。我们很快就会更改这个测试,使其最终比现在更有价值,并且我们会移除尝试进行五次请求的循环。分步骤进行这些更改将使我们能够验证我们没有破坏任何主要的东西。并且期望得到略微不同的结果将使我们能够验证我们是否使用了不同的计算函数。

要构建项目,我们需要定义gService2,它将使用一个新的设置和销毁类。将以下代码添加到SetupTeardown.h中:

class TestServiceSetup
{
public:
    TestServiceSetup ()
    : mService(SimpleService::testCalc)
    { }
    void setup ()
    {
        mService.start();
    }
    void teardown ()
    {
    }
    SimpleService::Service & service ()
    {
        return mService;
    }
private:
    SimpleService::Service mService;
};
extern MereTDD::TestSuiteSetupAndTeardown<TestServiceSetup>
gService2;

TestServiceSetup类定义了一个构造函数,该构造函数使用testCalc函数初始化mService数据成员。gService2声明使用TestServiceSetup。我们需要在SetupTeardown.cpp中对gService2进行一些小的更改,如下所示:

#include "SetupTeardown.h"
MereTDD::TestSuiteSetupAndTeardown<ServiceSetup>
gService1("Calculation Service", "Service 1");
MereTDD::TestSuiteSetupAndTeardown<TestServiceSetup>
gService2("Calculation Test Service", "Service 2");

SetupTeardown.cpp文件很短,只需要定义gService1gService2的实例。

我们需要修改testCalc函数,使其乘以8后得到预期的结果40而不是50。以下是Service.cpp中的两个计算函数:

void SimpleService::normalCalc (
    int seed, int & progress, int & result)
{
    progress = 100;
    result = seed * 10;
}
void SimpleService::testCalc (
    int seed, int & progress, int & result)
{
    progress = 100;
    result = seed * 8;
}

构建和运行测试应用程序显示所有测试都通过了。我们现在有两个测试套件。输出如下:

Running 2 test suites
--------------- Suite: Service 1
------- Setup: Calculation Service
Passed
------- Test: Calculate request can be sent
Passed
------- Test: Status request generates result
Passed
------- Teardown: Calculation Service
Passed
--------------- Suite: Service 2
------- Setup: Calculation Test Service
Passed
------- Test: Status request to test service generates result
Passed
------- Teardown: Calculation Test Service
Passed
-----------------------------------
Tests passed: 7
Tests failed: 0

在这里,我们引入了一个使用略微不同的计算函数的新服务,并且可以在测试中使用这两个服务。测试通过,且仅进行了最小改动。现在,我们准备进行更多更改以协调线程。这种方法比直接跳入线程管理代码并添加新服务和计算函数要好。

在遵循 TDD(测试驱动开发)时,过程始终相同:让测试通过,对测试进行小改动或添加新测试,然后再次让测试通过。

下一步将完成这一部分。我们将控制testCalc函数的工作速度,以便我们可以进行多次状态请求以获得完整的结果。我们将在测试计算函数内部等待,以便测试有时间验证进度确实随着时间的推移而增加,直到进度达到 100%时最终计算出结果。

让我们从测试开始。我们将在测试线程内部向计算线程发送信号,以便计算线程能够与测试同步进行。这就是我不使用睡眠来测试多个线程的意思。在线程内部睡眠不是一个好的解决方案,因为它不可靠。你可能能够通过测试,但后来当时间变化时,同样的测试可能会失败。这里你将学到的解决方案可以应用于你的测试。

你需要做的只是创建你代码的一部分的测试版本,它可以替换真实代码。在我们的例子中,我们有一个testCalc函数可以替换normalCalc函数。然后,你可以在测试中添加一个或多个条件变量,并在你的代码的测试版本中等待这些条件变量。条件变量是 C++中一个标准且受支持的方式,允许一个线程在满足条件之前等待。测试计算函数将等待条件变量。当测试准备好继续计算时,它将通知条件变量。通知条件变量将在正确的时间解除等待的计算线程,以便测试可以验证适当的线程行为。然后,测试将等待计算完成后再继续。我们需要在Service.h的顶部包含condition_variable,如下所示:

#ifndef SIMPLESERVICE_SERVICE_H
#define SIMPLESERVICE_SERVICE_H
#include <condition_variable>
#include <string>
#include <variant>

然后,我们需要在Service.h中声明一个互斥锁、两个条件变量和两个布尔值,以便它们可以被测试计算函数和测试使用。让我们在测试计算函数之前声明互斥锁、条件变量和布尔值,如下所示:

void normalCalc (int seed, int & progress, int & result);
extern std::mutex service2Mutex;
extern std::condition_variable testCalcCV;
extern std::condition_variable testCV;
extern bool testCalcReady;
extern bool testReady;
void testCalc (int seed, int & progress, int & result);

这里是修改后的Message.cpp测试代码:

TEST_SUITE("Status request to test service generates result", "Service 2")
{
    std::string user = "123";
    std::string path = "";
    SimpleService::RequestVar calcRequest =
        SimpleService::CalculateRequest {
            .mSeed = 5
        };
    auto responseVar = gService2.service().handleRequest(
        user, path, calcRequest);
    auto const calcResponse =
        std::get_if<SimpleService::CalculateResponse>        (&responseVar);
    CONFIRM_TRUE(calcResponse != nullptr);
    // Make a status request right away before the service
    // is allowed to do any calculations.
    SimpleService::RequestVar statusRequest =
        SimpleService::StatusRequest {
            .mToken = calcResponse->mToken
        };
    responseVar = gService2.service().handleRequest(
        user, path, statusRequest);
    auto statusResponse =
        std::get_if<SimpleService::StatusResponse>        (&responseVar);
    CONFIRM_TRUE(statusResponse != nullptr);
    CONFIRM_FALSE(statusResponse->mComplete);
    CONFIRM_THAT(statusResponse->mProgress, Equals(0));
    CONFIRM_THAT(statusResponse->mResult, Equals(0));
    // Notify the service that the test has completed the first
    // confirmation so that the service can proceed with the
    // calculation.
    {
        std::lock_guard<std::mutex>              lock(SimpleService::service2Mutex);
        SimpleService::testReady = true;
    }
    SimpleService::testCV.notify_one();
    // Now wait until the service has completed the calculation.
    {
        std::unique_lock<std::mutex>              lock(SimpleService::service2Mutex);
        SimpleService::testCalcCV.wait(lock, []
        {
            return SimpleService::testCalcReady;
        });
    }
    // Make another status request to get the completed result.
    responseVar = gService2.service().handleRequest(
        user, path, statusRequest);
    statusResponse =
        std::get_if<SimpleService::StatusResponse>        (&responseVar);
    CONFIRM_TRUE(statusResponse != nullptr);
    CONFIRM_TRUE(statusResponse->mComplete);
    CONFIRM_THAT(statusResponse->mProgress, Equals(100));
    CONFIRM_THAT(statusResponse->mResult, Equals(40));
}

测试比以前要长一些。我们不再在寻找完成响应的同时在循环中发出状态请求。这个测试采取了一种更谨慎的方法,并且确切地知道每个步骤的期望结果。初始的计算请求和计算响应是相同的。测试知道计算将被暂停,因此第一个状态请求将返回一个未完成的响应,进度为零。

在第一次状态请求被确认后,测试会通知计算线程可以继续,然后测试等待。一旦计算完成,计算线程将通知测试可以继续。在所有时候,测试和计算线程都在轮流进行,这样测试可以确认每一步。测试计算线程中存在一个小小的竞争条件,我会在你看到代码后解释。竞争条件是指两个或多个线程可能会相互干扰,导致结果不可完全预测的问题。

现在我们来看另一半——测试计算函数。我们需要声明互斥锁、条件变量以及布尔值。变量和测试计算函数应该看起来像这样:

std::mutex SimpleService::service2Mutex;
std::condition_variable SimpleService::testCalcCV;
std::condition_variable SimpleService::testCV;
bool SimpleService::testCalcReady {false};
bool SimpleService::testReady {false};
void SimpleService::testCalc (
    int seed, int & progress, int & result)
{
    // Wait until the test has completed the first status request.
    {
        std::unique_lock<std::mutex> lock(service2Mutex);
        testCV.wait(lock, []
        {
            return testReady;
        });
    }
    progress = 100;
    result = seed * 8;
    // Notify the test that the calculation is ready.
    {
        std::lock_guard<std::mutex> lock(service2Mutex);
        testCalcReady = true;
    }
    testCalcCV.notify_one();
}

测试计算函数的第一件事是等待。除非测试有机会确认初始状态,否则不会进行任何计算进度。一旦允许测试计算线程继续,它需要在返回之前通知测试,以便测试可以再次进行状态请求。

理解这个过程的最重要的地方是,测试计算函数应该是唯一与测试交互的代码。你不应该在主服务响应处理器中,甚至是在响应处理器中定义的 lambda 中放置任何等待或通知。只有替换为实际计算函数的测试计算函数应该有测试正在运行的任何意识。换句话说,你应该将所有的等待和条件变量通知放在testCalc中。这就是我提到的竞争条件的来源。当testCalc函数通知测试线程计算已完成时,这并不完全正确。只有当setData完成更新CalcRecord时,计算才算完成。然而,我们不想在调用setData后发送通知,因为这会将通知放在testCalc函数之外。

理想情况下,我们会在计算完成后调用计算函数一次额外的次数。我们可以说这给了计算函数一个清理计算期间使用的任何资源的机会。或者也许我们可以创建另一组用于清理的函数。一个清理函数可以是正常的清理,而另一个函数可以是用于测试清理的替代品。任何一种方法都可以让我们通知测试计算已完成,这将消除竞争条件。

构建和运行这些测试表明所有测试仍然通过。我们几乎完成了。我们将保持竞争条件不变,因为修复它只会给这个解释增加额外的复杂性。唯一剩下的任务是在日志文件中修复我注意到的问题。我将在下一节中解释这个新问题的更多内容。

修复最后通过日志检测到的问题

我选择在本书的第二部分日志库中构建日志库有一个很大的原因。日志记录在调试已知问题时可以提供巨大的帮助。常常被忽视的是,日志记录在寻找尚未检测到的错误时提供的益处。

我通常会在运行测试后查看日志文件,以确保消息与我预期的相符。在上一节中对测试和测试计算线程之间的线程协调进行了增强后,我在日志文件中注意到了一些奇怪的现象。日志文件看起来是这样的:

2022-08-27T05:00:50.409 Service is starting.
2022-08-27T05:00:50.410 user="123" Received Calculate request for: 5
2022-08-27T05:00:50.411 user="123" Received Calculate request for: 5
2022-08-27T05:00:50.411 user="123" Received Status request for: 1
2022-08-27T05:00:50.411 Service is starting.
2022-08-27T05:00:50.411 Service is starting.
2022-08-27T05:00:50.411 user="123" Received Calculate request for: 5
2022-08-27T05:00:50.411 user="123" Received Calculate request for: 5
2022-08-27T05:00:50.411 user="123" Received Status request for: 2
2022-08-27T05:00:50.411 user="123" Received Status request for: 2
2022-08-27T05:00:50.411 user="123" Received Status request for: 2
2022-08-27T05:00:50.411 user="123" Received Status request for: 2

我移除了log_levellogpath标签,只是为了缩短消息,以便你能更好地看到重要部分。我首先注意到的一个奇怪现象是服务启动了三次。我们只有gService1gService2,所以服务应该只启动了两次。

日志文件的前四行是有意义的。我们启动gService1,然后运行一个简单的测试,请求一个计算并检查响应是否为正确的类型。然后,我们运行另一个测试,在寻找完整响应的同时,最多进行五次状态请求。第一次状态请求找到了完整的响应,因此不需要额外的状态请求。第一次状态请求的令牌是1

日志文件的第 5 行,即第二次启动服务的地方,是日志文件开始看起来奇怪的地方。我们只需要启动第二个服务,进行一次额外的请求,然后进行两次状态请求。看起来日志文件从第 5 行到结尾都在接收重复的消息。

经过一点调试和提示我们正在重复日志消息后,我发现问题所在。当我最初设计该服务时,我在Service::start方法中配置了日志记录。我应该将日志配置保留在main函数中。一切正常,直到我们需要创建并启动第二个服务,以便第二个服务可以配置为使用测试计算函数。嗯,第二个服务在启动时也配置了日志,并添加了另一个文件输出。第二个文件的输出导致所有日志消息被发送到日志文件两次。解决方案很简单:我们需要像这样在main中配置日志:

#include <MereMemo/Log.h>
#include <MereTDD/Test.h>
#include <iostream>
int main ()
{
    MereMemo::FileOutput appFile("logs");
    MereMemo::addLogOutput(appFile);
    return MereTDD::runTests(std::cout);
}

然后,我们需要从服务的start方法中移除日志配置,使其看起来像这样:

void SimpleService::Service::start ()
{
    MereMemo::log(info) << "Service is starting.";
}

通过这些更改,测试仍然通过,日志文件看起来更好。再次,我移除了一些标签以缩短日志消息行。现在,日志文件的内容如下:

2022-08-27T05:35:30.573 Service is starting.
2022-08-27T05:35:30.574 user="123" Received Calculate request for: 5
2022-08-27T05:35:30.574 user="123" Received Calculate request for: 5
2022-08-27T05:35:30.574 user="123" Received Status request for: 1
2022-08-27T05:35:30.574 Service is starting.
2022-08-27T05:35:30.574 user="123" Received Calculate request for: 5
2022-08-27T05:35:30.574 user="123" Received Status request for: 2
2022-08-27T05:35:30.575 user="123" Received Status request for: 2

虽然问题最终是日志配置错误导致的,但我想要强调的是,提醒你定期查看日志文件,确保日志消息是有意义的。

摘要

这是本书的最后一章,它解释了编写软件中最令人困惑和难以理解的一个方面:如何测试多线程。你会发现很多书籍解释了多线程,但很少会给你提供建议并展示有效测试多线程的方法。

由于本书的目标客户是希望学习如何使用 TDD 来设计更好软件的微服务 C++开发者,因此本章将本书中的所有内容串联起来,解释如何测试多线程服务。

首先,你学会了如何在测试中使用多个线程。你需要确保你处理在启动额外线程的测试中出现的异常。异常很重要,因为测试库使用异常来处理失败的确认。你还学会了如何使用一个特殊的辅助类来报告在额外线程中出现的失败确认。

在编写和使用库时,也必须考虑线程。你看到了如何测试库以确保它是线程安全的。

最后,你学会了如何以快速和可靠的方式测试多线程服务,避免了在尝试协调多个线程的动作时让线程休眠。你学会了如何重构你的代码,以便尽可能地在单线程模式下进行测试,然后如何用特殊的测试感知代码替换正常代码,这些代码与测试一起工作。当你需要测试和多线程代码一起工作时,你可以使用这种技术,以便测试可以采取具体和可靠的步骤,并在过程中确认你的期望。

恭喜你完成了这本书的阅读!本章回顾了我们一直在工作的所有项目。我们增强了单元测试库,帮助你可以在测试中使用多个线程。我们还使日志库线程安全。最后,我们增强了服务,使其能够在服务和测试之间协调多个线程。你现在拥有了将 TDD 应用到你的项目中所需要的所有技能。

posted @ 2025-10-02 09:35  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报