C--和--NET-测试驱动开发实用指南-全-
C# 和 .NET 测试驱动开发实用指南(全)
原文:
zh.annas-archive.org/md5/090175b8503a2fd03da8637f3e71ad3e译者:飞龙
前言
作为一名顾问,我与多个组织中的许多团队合作过。我见过进行 TDD 的团队,也见过没有进行 TDD 但进行单元测试的团队。我还见过认为自己在进行单元测试但实际上在进行集成测试的团队,以及没有进行任何测试的团队!作为一个普通人,我开始根据经验证据形成信念,认为进行 TDD 的团队是最成功的,但这并不是因为他们使用了 TDD!TDD 的结果来自于拥有激情。
TDD 是单元测试加上激情。在一些团队中,单元测试是强制的,因此开发者必须执行它,但 TDD 很少被强制执行,这取决于开发者自己来强制执行。不用说,充满激情的开发者会创造出高质量的项目,而高质量的项目更有可能成功。
TDD 通常与领域驱动设计(DDD)架构的某些或全部方面相结合。因此,我确保我涵盖了 TDD 和 DDD 的结合,以便能够给出现实生活中的例子。我还想反映今天的市场,它被关系型数据库和文档数据库这两个数据库类别所分割,所以我自由地包括了每个类别的示例章节,并展示了单元测试实现中的差异,目的是使本书保持实用。
不要被本书的篇幅所欺骗,因为图表和代码片段确实增加了本书的篇幅。我努力避免过时和不实用的理论,以缩短本书的篇幅并保持重点突出。
TDD 和单元测试在大多数现代职位说明中都是要求,是面试测试项目的必备条件,也是热门面试问题的主题。如果你想要了解更多关于这些主题的信息,并成为一名 TDD 开发者,那么你来到了正确的地点。
关于 TDD 的许多其他优秀书籍也针对.NET 开发者,那么为什么这本书? 在这本书中,我通过进入 DDD 世界、关系型数据库和文档数据库,展示了真正的实际实现。我展示了实践者在进行 TDD 时使用的思维模式的决策树。我展示了 SOLID 与 TDD 之间的关系,并介绍了一套被称为 TDD 的 FIRSTHAND 指南的易于记忆的最佳实践。
我写这本书的目的是让你成为一个自信的 TDD 实践者,或者至少是一个单元测试实践者,我希望我已经实现了我的目标。
本书面向对象
测试驱动开发是从第一天开始设计、记录和测试你的应用程序的主流方式。作为一名希望攀登技术阶梯到更高职位的高级开发者,TDD 及其相关的单元测试、测试替身和依赖注入主题是必须学习的。
本书面向中级至高级.NET 开发者,旨在利用 TDD 的潜力来开发高质量的软件。假设读者具备 OOP 和 C#编程概念的基本知识,但不需要了解 TDD 或单元测试。本书全面覆盖了 TDD 和单元测试的所有概念,并作为开发者从零开始构建基于 TDD 的应用或计划将单元测试引入其组织的优秀指南。
本书涵盖的内容
本书涵盖了 TDD 及其.NET 生态系统中的 IDE 和库,并介绍了环境设置。本书首先涵盖了构成 TDD 先决条件的话题,即依赖注入、单元测试和测试替身。然后,在介绍 TDD 及其最佳实践之后,本书深入探讨了使用领域驱动设计作为架构从头开始构建应用。
本书还涵盖了构建持续集成管道的基础、处理未考虑可测试性的遗留代码,并以将 TDD 引入组织的想法结束。
第一章, 编写您的第一个 TDD 实现,没有长篇介绍或理论,而是直接进入 IDE 选择和编写您的第一个 TDD 实现,以体验本书内容的味道。
第二章, 通过示例理解依赖注入,复习了理解依赖注入概念所需的先进 OOP 原则,并提供了多个示例。
第三章, 开始使用单元测试,提供了对 xUnit 和单元测试基础简单介绍。
第四章, 使用测试替身进行真实单元测试,介绍了存根、模拟和 NSubstitute,然后讨论了更多测试类别。
第五章, 测试驱动开发解析,展示了如何以 TDD 风格编写单元测试,并讨论了其优缺点。
第六章, TDD 的 FIRSTHAND 指南,详细介绍了单元测试和 TDD 的最佳实践。
第七章, 对领域驱动设计的实用看法,介绍了 DDD、服务和存储库。
第八章, 设计预约应用,概述了将使用 DDD 架构和 TDD 风格在以后实现的现实应用规范。
第九章, 使用 Entity Framework 和关系型数据库构建预约应用,演示了使用关系型数据库后端的一个 TDD 应用示例。
第十章,使用仓储和文档数据库构建应用程序,演示了使用文档数据库和仓储模式的一个 TDD 应用程序示例。
第十一章,使用 GitHub Actions 实现持续集成,展示了如何使用 GitHub Actions 为 第十章 中的应用程序构建 CI 管道。
第十二章,处理遗留项目,概述了在考虑遗留项目的 TDD 和单元测试时的思考过程。
第十三章,推广 TDD 的复杂性,解释了在组织采用 TDD 时的思维过程。
附录 1,常用库与单元测试,展示了 MSTest、NUnit、Moq、Fluent Assertions 和 Auto Fixture 的快速示例。
附录 2,高级模拟场景,展示了使用 NSubstitute 的更复杂的模拟场景。
为了充分利用这本书
本书假设您熟悉 C# 语法,并且至少有一年的使用 Visual Studio 或类似 IDE 环境的经验。虽然本书将复习 OOP 原则的高级概念,但本书假设您熟悉基础知识。
| 本书涵盖的软件 | 操作系统要求 |
|---|---|
| Visual Studio 2022 | Windows 或 macOS |
| 精细代码覆盖率 | Windows |
| SQL Server | Windows, macOS (Docker), 或 Linux |
| Cosmos DB | Windows, macOS (Docker) 或 Linux (Docker) |
| 库和框架 | 操作系统要求 |
| .NET Core 6, C# 10 | Windows, macOS 或 Linux |
| xUnit | Windows, macOS 或 Linux |
| NSubstitute | Windows, macOS 或 Linux |
| Entity Framework | Windows, macOS 或 Linux |
为了充分利用这本书,你需要有一个 C# 集成开发环境。本书使用 Visual Studio 2022 社区版,并在 第一章 的开头介绍了替代方案。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/OzRlM。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“前面的代码违反了此规则,因为先运行UnitTest2再运行UnitTest1将导致测试失败。”
代码块设置如下:
public class SampleTests
{
private static int _staticField = 0;
[Fact]
public void UnitTest1()
{
_staticField += 1;
Assert.Equal(1, _staticField);
}
[Fact]
public void UnitTest2()
{
_staticField += 5;
Assert.Equal(6, _staticField);
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public class SampleTests
{
private static int _staticField = 0;
[Fact]
public void UnitTest1()
{
_staticField += 1;
Assert.Equal(1, _staticField);
}
[Fact]
public void UnitTest2()
{
_staticField += 5;
Assert.Equal(6, _staticField);
}
}
任何命令行输入或输出都按以下方式编写:
GET https://webapidomain/services
粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“安装本地模拟器后,您需要获取连接字符串,您可以通过浏览到localhost:8081/_explorer/index.xhtml并从主连接字符串字段复制连接字符串来完成此操作。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Pragmatic Test-Driven Development in C# and .NET》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。
第一部分:入门和 TDD 的基本知识
在这部分,我们将逐步介绍构成测试驱动开发的所有概念——从依赖注入开始,经过测试替身,最后到 TDD 指南和最佳实践。
在本部分结束时,你将具备使用 TDD 参与应用程序开发的必要知识。本部分包括以下章节:
-
第一章, 编写你的第一个 TDD 实现
-
第二章, 通过示例理解依赖注入
-
第三章, 单元测试入门
-
第四章, 使用测试替身进行真实单元测试
-
第五章, 测试驱动开发详解
-
第六章, TDD 的亲身体验指南
第一章:编写你的第一个 TDD 实现
我一直喜欢那些在深入细节之前,先快速演示一下所提议主题的书籍。这让我对将要学习的内容有一个大致的了解。我希望通过以一个小型应用程序开始这本书,与你分享同样的体验。
在这里,我们将模拟最小化的业务需求,并在实现它们的过程中,会涉及到单元测试和测试驱动开发(TDD)的概念。如果某个概念不清楚或需要进一步解释,请不要担心,因为这一章故意略过了这些主题,以便给你一个大致的了解。到书的结尾,我们将涵盖所有被略过的话题。
此外,请注意,我们将互换使用单元测试和TDD这两个术语,几乎不加区分。区别将在第五章“测试驱动开发解释”中变得更加清晰。
在本章中,你将涵盖以下主题:
-
选择你的集成开发环境(IDE)
-
使用单元测试构建解决方案框架
-
使用 TDD 实现需求
到本章结束时,你将能够熟练地使用xUnit编写基本的单元测试,并对 TDD 有一个公正的理解。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch01
选择你的 IDE
从 TDD 的角度来看,不同的 IDE 会影响你的生产力。具有丰富代码重构和代码生成功能的 IDE 可以提升 TDD 的实现,选择正确的一个将减少重复——甚至可能令人厌烦——的任务。
在接下来的几节中,我介绍了三个支持 C#的流行 IDE:Visual Studio(VS)、VS Code和JetBrains Rider。
微软 VS
本章以及本书的其余部分将使用VS 2022 社区版——这也应该适用于专业版和企业版。个人开发者可以免费使用VS 社区版来创建自己的免费或付费应用程序。组织也可以在一定的限制下使用它。有关完整的许可证和产品详细信息,请访问visualstudio.microsoft.com/vs/community/。
如果你有一个更早版本的 VS 并且不想升级,那么你可以将VS 2022 社区版与之前的版本并行安装。
VS 2022的Windows和Mac版本都包含了构建我们的代码和运行测试所需的工具。我在这本书中所有的项目、截图和说明都是使用Windows版本完成的。你可以从visualstudio.microsoft.com/downloads/下载 VS。
在安装 VS 时,您至少需要选择 ASP.NET 和 Web 开发选项,才能按照以下截图进行操作:

图 1.1 – VS 安装对话框
如果您之前已经安装了 VS,可以通过以下步骤检查是否已安装 ASP.NET 和 Web 开发:
-
前往 Windows 设置 | 应用 | 应用和功能。
-
在 应用列表 中搜索 Visual Studio。
-
选择垂直省略号(三个垂直点)。
-
选择如图所示的操作 修改:

图 1.2 – 修改 VS 安装
VS 很大,因为它包含许多需要安装的组件。此外,与 Rider 和 VS Code 相比,安装后它加载速度最慢。
ReSharper
JetBrains ReSharper 是 VS 的一个流行的商业插件。ReSharper 为 VS 添加了多个功能;然而,从 TDD 的角度来看,我们感兴趣的是以下方面:
-
重构:ReSharper 添加了许多在 TDD 的重构阶段非常有用的重构功能。
-
代码生成:使用 ReSharper 生成代码特别有用,尤其是在首先创建单元测试然后生成代码的情况下。
-
单元测试:ReSharper 加强了 VS 的单元测试工具,并支持更多的单元测试框架。
ReSharper 是一款基于订阅的产品,提供 30 天的试用期。我建议您首先使用不带 ReSharper 的 VS,然后在熟悉 VS 的功能后再添加它,这样您就能认识到添加 ReSharper 的好处。
注意
VS 的每个新版本都会添加额外的代码重构和代码生成功能,类似于 ReSharper 的功能。然而,截至目前,ReSharper 拥有更先进的功能。
在本书中,关于 ReSharper 的讨论将仅限于本节。您可以从这里下载 ReSharper:www.jetbrains.com/resharper/。
JetBrains Rider
JetBrains,Rider 背后的公司,也是流行的 ReSharper VS 插件的背后公司。如果您选择了 JetBrains Rider 进行 .NET 开发,那么您将拥有本书中所需的所有功能。Rider 具有以下特点:
-
一个强大的 单元测试运行器,与 VS 的 测试资源管理器相媲美
-
丰富的代码重构和代码生成功能,比 VS 2022 的功能更先进
上述要点对于以 TDD 风格 构建系统至关重要;然而,我选择在本书中使用 VS 而不是 Rider。尽管本书中的说明是针对 VS 2022 的,但考虑到 Rider 拥有不同的菜单系统和快捷键,它们也可以应用于 Rider。
注意
VS .NET(带有 .NET 支持的 VS 版本)于 2002 年 2 月发布,而 Rider 更新,于 2017 年 8 月发布;因此,VS 在 .NET 开发者中更为成熟。我之所以选择 VS 而不是 Rider 来编写这本书,是因为这个原因。
您可以在此处下载 Rider:www.jetbrains.com/rider/.
VS Code
如果你喜欢 VS Code,你将很高兴地知道,微软在 2021 年 7 月的版本 1.59 发布中添加了对可视化单元测试的原生支持(这对于 TDD 是必不可少的)。
VS Code 是一个轻量级 IDE——它具有良好的原生重构选项和一系列第三方重构插件。VS Code 的简洁和优雅吸引了众多 TDD 实践者,但其 C# 功能——尤其是在 TDD 中使用的功能——并不像 VS 或 Rider 那样先进。
我将在这本书中使用 VS,但你可以将示例适应到相关的 VS Code 中。要下载 VS Code,你可以访问 visualstudio.microsoft.com/downloads/.
.NET 和 C# 版本
VS 2022 支持 .NET 6 和 C# 10。这是我们将在本章和本书其余部分使用的内容。
我在我的 LinkedIn 群组中发起了一项小调查,以收集一些公众意见——你可以在这里看到结果:

图 1.3 – LinkedIn IDE 投票结果
如你所见,VS 的使用率最高,为 58%,其次是使用 VS 和 ReSharper 插件的 18%,然后是排名第二的 Rider,占 24%,第三名是 VS Code,占 18%。然而,鉴于这只有 45 票,这只是为了给你一个指示,肯定不会反映市场情况。
选择合适的 IDE 是开发者之间有争议的话题。我知道每次我询问实践 TDD 的开发者他们选择的 IDE 时,他们都会发誓他们的 IDE 多么好!总之,使用能让你更高效的 IDE。
使用单元测试构建解决方案框架
现在我们已经解决了技术要求,是时候构建我们的第一个实现了。为了本章,并保持对 TDD 概念的关注,让我们从简单的业务需求开始。
假设你是一名开发者,在一家名为 Unicorn Quality Solutions Inc.(UQS)的虚构公司工作,该公司生产高质量的软件。
需求
UQS 中的软件团队采用敏捷方法,并以 用户故事 的形式描述业务需求。
你正在开发一个打包供其他开发者使用的数学库。你可以将其视为在 NuGet 库 中构建一个功能,以便其他应用程序使用。你已经选择了一个用户故事来实现,如下所述:
故事标题:
整数除法
故事描述:
作为数学库客户端,我希望有一个方法来除以两个整数
验收标准:
支持 Int32 输入和十进制输出
支持高精度返回,无/最小舍入
支持除以可除和不可除的整数
当除以 0 时抛出 DivideByZeroException 异常
创建项目骨架
对于这个故事,你需要两个 C#项目。一个是包含生产代码的类库,另一个是用于对类库进行单元测试的库。
注意
类库使您能够模块化可以被多个应用程序使用的功能。编译后,它们将生成动态链接库(DLL)文件。类库不能独立运行,但它可以作为应用程序的一部分运行。
如果您之前没有使用过类库,为了本书的目的,您可以将其视为控制台应用程序或 Web 应用程序。
创建类库项目
我们将以两种方式创建相同的项目设置——通过图形用户界面(GUI)和通过.NET 命令行界面(CLI)。选择您喜欢的或您熟悉的方式。
通过图形用户界面(GUI)
要创建类库,运行 VS,然后按照以下步骤操作:
-
从菜单中选择文件 | 新建 | 项目。
-
查找
Class Library (C#)。 -
选择包含类库(C#)的矩形 | 点击下一步。将显示添加新项目对话框,如下所示:

图 1.4 – 查找类库(C#)项目模板
重要提示
确保您可以看到框中的C#标签,并且不要选择类库 (.NET Framework)项。我们使用.NET(而不是经典的.NET Framework)。
- 在
UqsMathLib中的Uqs.Arithmetic的解决方案名称字段中,然后点击下一步。过程如图下所示:

图 1.5 – 配置新项目对话框
- 在
.NET 6.0 (长期支持)中点击创建。过程如图下所示:

图 1.6 – 其他信息
我们现在在解决方案中有一个类库项目,使用的是VS GUI。
通过命令行界面(CLI)
如果您更喜欢通过命令行界面(CLI)创建项目,以下是所需的命令:
-
创建一个名为
UqsMathLib的目录(md UqsMathLib)。 -
通过您的终端(
cd UqsMathLib)导航到该目录,如图下所示:

图 1.7 – 命令提示符显示命令
-
通过运行以下命令创建一个与目录同名的解决方案文件(
.sln),即UqsMathLib.sln:dotnet new sln -
在同名的目录中创建一个名为
Uqs.Arithmetic的新类库,并使用.NET 6.0。以下是您需要执行的代码:dotnet new classlib -o Uqs.Arithmetic -f net6.0 -
通过运行以下命令将新创建的项目添加到解决方案文件中:
dotnet sln add Uqs.Arithmetic
现在我们解决方案中有一个类库项目,使用的是 CLI。
创建单元测试项目
目前,我们有一个包含一个类库项目的解决方案。接下来,我们想要将单元测试库添加到我们的解决方案中。为此,我们将使用xUnit 测试项目。
xUnit.net 是一个免费的、开源的 .NET 单元测试工具。它根据 Apache 2 许可。VS 本地支持添加和运行 xUnit 项目,因此不需要特殊工具或插件来使用 xUnit。
我们将在第三章“开始单元测试”中更详细地介绍 xUnit。
我们将遵循命名单元测试项目的通用约定:[ProjectName].Tests.Unit。因此,我们的项目将被称为Uqs.Arithmetic.Tests.Unit。
我们将以两种方式创建单元测试项目,这样您可以选择最适合您的方法。
通过 GUI
要创建单元测试项目,请转到 VS 中的解决方案资源管理器,然后按照以下步骤操作:
-
右键单击解决方案文件(
UqsMathLib)。 -
前往添加 | 新建项目…,如图所示:

图 1.8 – 在解决方案中创建新项目
-
查找xUnit 测试项目 | 点击下一步。
-
设置
Uqs.Arithmetic.Tests.Unit。 -
点击下一步 | 选择.NET 6.0 | 点击创建。
您已通过 VS GUI 创建了一个项目,但我们仍然需要设置单元测试项目以对类库有引用。为此,请按照以下步骤操作:
-
在 VS 解决方案资源管理器中,右键单击
Uqs.Arithmetic.Tests.Unit。 -
选择添加项目引用…。
-
选择
Uqs.Arithmetic并点击确定。
现在我们已经通过 VS 图形用户界面(GUI)完全构建了解决方案。您可以选择在 CLI 中执行相同的 GUI 步骤。在下一节中,我们将做 exactly that。
通过 CLI
目前,我们有一个包含一个类库项目的解决方案。现在,我们想要将单元测试库添加到我们的解决方案中。
在具有相同名称的目录中创建一个新的 xUnit 项目,名为 Uqs.Arithmetic.Tests.Unit,并使用 .NET 6.0。以下是您需要执行的代码:
dotnet new xunit -o Uqs.Arithmetic.Tests.Unit -f net6.0
通过运行以下命令将新创建的项目添加到解决方案文件中:
dotnet sln add Uqs.Arithmetic.Tests.Unit
现在我们解决方案中有两个项目。由于单元测试项目将测试类库,因此项目应该有对类库的引用。
您已通过命令行界面(CLI)创建了一个项目,但我们仍然需要将单元测试项目设置为对类库有引用。为此,从Uqs.Arithmetic.Tests.Unit添加一个项目引用到Uqs.Arithmetic,如下所示:
dotnet add Uqs.Arithmetic.Tests.Unit reference
Uqs.Arithmetic
现在我们已经通过 CLI 完全构建了解决方案。
最终解决方案
无论您使用哪种方法创建解决方案——无论是 VS GUI 还是 CLI——您现在都应该有相同的文件创建。现在,您可以在 VS 中打开解决方案,您会看到以下内容:

图 1.9 – 最终创建的解决方案结构
为了从零开始,删除 Class1.cs,因为我们不会使用它——它是模板自动添加的。
我们两个项目的逻辑结构如下所示:

图 1.10 – 项目的逻辑结构
我们到目前为止创建了两个项目:一个将在某个阶段发布到生产环境(Uqs.Arithmetic)和一个用于测试此项目(Uqs.Arithmetic.Tests.Unit)。解决方案文件将这两个项目链接在一起。
现在我们已经完成了构建项目骨架和设置依赖项不那么有趣的部分,现在我们可以开始更有趣的部分,即与单元测试直接相关的内容。
熟悉内置测试工具
我们已经到达了需要了解如何发现和执行测试的阶段,为此,我们需要了解哪些工具可供我们使用。
我们已经有了由 xUnit 模板生成的代码——看看这里显示的 UnitTest1.cs 内的代码:
using Xunit;
namespace Uqs.Arithmetic.Tests.Unit;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
这是一个正常的 C# 类。Fact 是来自 xUnit 的一个属性。它只是告诉任何与 xUnit 兼容的工具,被 Fact 装饰的方法是一个 单元测试方法。与 xUnit 兼容的工具,如 测试资源管理器 和 .NET CLI 测试命令 应该能够在您的解决方案中找到此方法并运行它。
沿着前几节的趋势,我们将以两种方式利用可用的测试工具——通过 VS GUI 和通过 CLI。
通过 GUI
VS 内置了一个 GUI 作为测试运行器来发现和执行测试——它被称为测试资源管理器。要查看测试运行器如何发现测试方法,从菜单中选择测试 | 测试资源管理器。您将看到以下屏幕:

图 1.11 – 测试资源管理器显示未执行的测试
如您所见,它检测到了我们解决方案中的所有测试,以 项目名称 > 命名空间 > 类 > 方法 的层次结构显示测试。您还可以看到测试层次结构被灰色显示,并带有感叹号。这是一个标志,表明测试从未运行过。您可以点击左上角的 Fact。结果如下所示:

图 1.12 – 测试资源管理器显示已执行的测试结果
由于我们有一个空壳,所以不要期望任何花哨的功能,但至少测试会变成 绿色,您将知道您的设置正在工作。您可以使用类似的方式使用 CLI 发现和执行测试。
通过 CLI
您也可以通过使用命令提示符,进入解决方案目录,并执行以下命令来执行相同的测试:
dotnet test
这就是您将要得到的结果:

图 1.13 – .NET 测试命令发现和执行测试
运行此类命令将在我们想要自动化测试运行时派上用场。
使用 TDD 实现需求
在编写任何代码之前,理解一些术语和约定来调整我们的思维,关注单元测试相关的关键词是有意义的。因此,我们将简要介绍系统测试对象(SUT)、红/绿测试和安排-行动-断言(AAA)。这些术语的更多细节将在后面的章节中介绍,但现在我们将介绍运行几个测试所需的最小内容。
当我们学习术语和约定时,我们将逐步进入实现。你可能发现的一个新或不寻常的地方是先编写单元测试,然后编写生产代码。这是 TDD 的一个主要方面,你将在本节中首次体验它。
SUT
我们通常编写的用于构建产品的代码称为生产代码。典型的面向对象(OO)生产代码看起来像这样:
public class ClassName
{
public Type MethodName(…)
{
// Code that does something useful
}
// more code
}
当我们测试这段代码时,单元测试将调用MethodName并评估该方法的性能。当MethodName执行时,它可能会调用类的其他部分,并可能使用/调用其他类。由MethodName执行的代码被称为 SUT 或待测试代码(CUT)。然而,SUT这个术语使用得更频繁。
SUT 将有一个入口点,该入口点将由单元测试执行。入口点通常是我们在单元测试中调用的方法。以下截图应该可以阐明 SUT 和 SUT 入口点的概念:

图 1.14 – 在 SUT 上运行的单元测试
在上一张截图,你可以看到多个单元测试调用相同的 SUT 入口点。关于 SUT 的详细讨论可以在第三章,开始单元测试中找到。
测试类
典型的单元测试类使用来自 SUT 的相同名称,这是约定俗成的。典型的单元测试类看起来是这样的:
public class ClassNameTests
{
[Fact]
public void MethodName_Condition1_Expectation1()
{
// Unit Testing Code that will call MethodName
}
// Other tests…
[Fact]
public void MethodName_ConditionN_ExpectationN()
{
// Unit Testing Code that will call MethodName
}
…
}
注意到在前两个代码片段中的ClassName和MethodName方法并不是巧合。我们希望它们通过约定保持一致。为了开始构建我们的测试类,我们需要设计类名和方法名。
类名
从需求中,我们需要一个包含所有我们的除法方法的类,所以我们可以简单地将其命名为Division;如果我们创建一个用于测试Division类的单元测试类,我们的单元测试名称将是DivisionTests。接下来,我们将UnitTest1类重命名为DivisionTests,并将文件名也相应地更改为DivisionTests.cs。
小贴士
你可以在源代码中的类名(在上一个例子中是UnitTest1)的任何位置设置你的文本光标,然后按Ctrl + R,R(按住Ctrl然后快速连续按两次R)。输入新名称DivisionTests并按Enter。如果勾选了重命名符号的文件复选框,这也会重命名文件。
方法名
幸运的是,要求很简单,所以我们的方法名将简单地是 Divide。根据要求,Divide 将接受两个整数(int32)参数,并返回一个 decimal 值。我们将继续重构我们的现有单元测试,从 Test1 更改为 Divide_Condition1_Expectation1。
注意
算术术语命名提示:如果我们有 10 / 5 = 2,那么 10 是被除数,5 是除数,2 是商。
条件和期望
当我们测试时,我们设置一个条件并定义当这个条件满足时我们期望什么。我们首先从 核心情况 开始,也称为 正向路径 或 快乐路径。在处理其他情况之前,我们首先完成所有正向路径。我们的单元测试任务归结为确定条件和它的期望,并为每一种组合编写一个单元测试。
为了展示我们正在测试的方法(我们的系统单元中的方法)与相关条件和期望之间的关系,我们将采用一个常用的约定,如下面的代码片段所示:
[Fact]
public void MethodName_Condition_Expectation()
{
…
这里有一些单元测试方法名的随机示例,以帮助您熟悉之前的约定:
-
SaveUserDetails_MissingEmailAddress_EmailIsMissing -
ValidateUserCredentials_HashedPasswordDoesntMatch_False -
GetUserById_IdDoesntExist_UserNotFoundException
在设计我们的单元测试时,我们将看到更多示例。
核心要求是除以两个整数。最直接和最简单的实现是除以可除整数并得到一个整数。我们的条件是 可除整数,我们期望得到一个 整数。现在,我们应该更新单元测试的签名为 Divide_DivisibleIntegers_WholeNumber 并编写测试方法的主体,如下所示:
[Fact]
public void Divide_DivisibleIntegers_WholeNumber()
{
int dividend = 10;
int divisor = 5;
decimal expectedQuotient = 2;
decimal actualQuotient = Division.Divide(dividend,
divisor);
Assert.Equal(expectedQuotient, actualQuotient);
}
这段代码无法编译,因为在这个阶段 Division 类不存在,我们已知这一点,因为我们在 Division 下有一个波浪线。这是少数几个由于缺少类而无法编译的情况之一,这种情况是好的。这表明我们的 测试失败了,这也是好的!
虽然测试失败看起来很愚蠢,因为代码无法编译,因为 Division 系统单元类缺失,但这意味着还没有 SUT 代码。在 第五章,测试驱动开发解释 中,我们将了解考虑无编译情况的原因。
Assert 是 xUnit 库中的一个类。Equal 静态方法有很多重载,其中之一我们在这里使用:
public static void Equal<T>(T expected, T actual)
当运行此方法时,如果我们所期望的和实际得到的是相等的,它将向 xUnit 框架标记。当我们运行这个测试时,如果这个断言的结果是 true,那么测试就通过了。
红色/绿色
失败正是我们所寻求的。在后续章节中,我们将讨论其原因。目前,只需知道我们需要从一个失败的构建(编译)或失败的测试(失败的断言)开始,然后将其更改为通过的状态。失败/通过也被称为红/绿重构技术,它模仿了坏/好和停止/继续的概念。
我们需要添加 Division 类和 Divide 方法,并编写最小化代码以使测试通过。在 Uqs.Arithmetic 项目中创建一个名为 Division.cs 的新文件,如下所示:
namespace Uqs.Arithmetic;
public class Division
{
public static decimal Divide(int dividend, int divisor)
{
decimal quotient = dividend / divisor;
return quotient;
}
}
小贴士
你可以通过将文本光标放在类名(在之前的例子中是 Division)内的任何位置,然后按 Ctrl + .(按住 Ctrl 键然后按 .)来创建一个类。选择 Uqs.Arithmetic,然后按 Divide 并按 Ctrl + .,选择 Division 准备编写你的代码。
重要的是要记住,在 C# 中,除以两个整数将返回一个整数。我见过一些资深开发者忘记这一点,这导致了不良后果。在我们实现的代码中,我们只涵盖了会产生整数商的整数除法。这应该能满足我们的测试。
现在我们已经准备好使用测试资源管理器运行我们的测试,所以按 Ctrl + R,A,这将构建你的项目,然后运行所有测试(目前有一个测试)。你会注意到测试资源管理器指示绿色,并且在测试名称和 Fact 属性之间有一个带有勾号的绿色项目符号。点击它将显示一些与测试相关的选项,如下面的截图所示:

图 1.15 – VS 单元测试气球
为了完整起见,完整的概念名称是 红/绿/重构,但在这里我们不会解释 重构 部分,并将这部分内容留到 第五章,“测试驱动开发解释”中。
AAA 模式
单元测试实践者注意到测试代码格式符合某种结构模式。首先,我们声明一些变量并做一些准备工作。这个阶段被称为 Arrange。
第二阶段是我们调用 SUT(系统单元)。在前面的测试中,这是调用 Divide 方法的行。这个阶段被称为 Act。
第三阶段是我们验证我们的假设——这是使用 Assert 类的地方。不出所料,这个阶段被称为 Assert。
开发者通常用注释将每个单元测试分成三个阶段,所以如果我们将这一点应用到我们之前的单元测试中,方法看起来会是这样:
[Fact]
public void Divide_DivisibleIntegers_WholeNumber()
{
// Arrange
int dividend = 10;
int divisor = 5;
decimal expectedQuotient = 2;
// Act
decimal actualQuotient = Division.Divide(dividend,
divisor);
// Assert
Assert.Equal(expectedQuotient, actualQuotient);
}
你可以在第三章,“开始单元测试”中了解更多关于 AAA 模式的信息。
更多测试
我们还没有完成需求的实现。我们需要通过迭代地添加新测试、检查它失败、实现它、然后使其通过,并重复这一过程来添加它们!
我们将在下一节中添加更多测试以覆盖所有要求,并且我们还将添加一些其他测试以提高质量。
除以不可除的数
我们需要覆盖一个情况,即两个数不能整除,所以我们就在第一个测试方法下添加另一个单元测试方法,如下所示:
[Fact]
public void Divide_IndivisibleIntegers_DecimalNumber()
{
// Arrange
int dividend = 10;
int divisor = 4;
decimal expectedQuotient = 2.5m;
…
}
这个单元测试方法与上一个类似,但方法名已更改以反映新的条件和期望。此外,数字也已更改以适应新的条件和期望。
通过以下任何一种方法运行测试:
-
点击出现在
Fact下方蓝色的子弹,然后点击 运行 -
打开
测试名称代码,并点击 运行 按钮 -
按下 Ctrl + R, A,这将运行所有测试
你会发现测试会失败——这是好事!我们还没有实现会产生小数的除法。现在我们可以继续这样做,如下所示:
decimal quotient = (decimal)dividend / divisor;
注意
在 C# 中,除以两个整数将返回一个整数,但除以一个整数将返回一个小数,因此你几乎总是必须将除数或被除数(或两者)转换为小数。
再次运行测试,这次应该会通过。
除以零测试
是的——除以零会发生坏事。让我们检查我们的代码是否可以处理这种情况,如下所示:
[Fact]
public void Divide_ZeroDivisor_DivideByZeroException()
{
// Arrange
int dividend = 10;
int divisor = 0;
// Act
Exception e = Record.Exception(() =>
Division.Divide(dividend, divisor));
// Assert
Assert.IsType<DivideByZeroException>(e);
}
Record 类是 xUnit 框架的另一个成员。Exception 方法记录 SUT 是否抛出了任何 Exception 对象,如果没有,则返回 null。这是该方法签名:
public static Exception Exception(Func<object> testCode)
IsType 是一个方法,它比较尖括号内的类类型与作为参数传递的对象的类类型,如下面的代码片段所示:
public static T IsType<T>(object @object)
当你运行这个测试时,它会通过!我的第一印象可能是怀疑。问题是,在没有编写显式代码的情况下通过,我们还不知道这是否是一个真正的或偶然的通过——一个假阳性。有许多方法可以验证这个通过是否是偶然的;目前最快的方法是调试 Divide_ZeroDivisor_DivideByZeroException 的代码。
点击 测试子弹,然后点击 调试 链接,如下面的截图所示:

图 1.16 – 单元测试气球中的调试选项
你将直接遇到异常,如下面的截图所示:

图 1.17 – 异常对话框
你会注意到异常确实发生在除法线上,这正是我们想要的。虽然这种方法违反了我们最初的红色/绿色尝试,但立即通过仍然是一个你在日常编码中会遇到的真实案例。
测试极端情况
故事中没有提到测试极端情况,但作为一个开发者,你知道大多数软件错误都来自边缘情况。
你希望对自己的现有代码更有信心,并确保它能够很好地处理极端情况,正如你所期望的那样。
int数据类型的极端值可以通过int的这两个常量字段获得:
-
int.MaxValue=![公式 1.1]()
-
int.MinValue=![公式 1.2]()
我们需要测试以下情况(请注意,我们只测试到 12 位小数):
-
int.MaxValue / int.MinValue = -0.999999999534 -
(-int.MaxValue) / int.MinValue = 0.999999999534 -
int.MinValue / int.MaxValue = -1.000000000466 -
int.MinValue / (-int.MaxValue) = 1.000000000466
因此,我们需要四个单元测试来覆盖每个情况。然而,大多数单元测试框架,包括 xUnit,都有一个技巧。我们不必编写四个单元测试——我们可以这样做:
[Theory]
[InlineData( int.MaxValue, int.MinValue, -0.999999999534)]
[InlineData(-int.MaxValue, int.MinValue, 0.999999999534)]
[InlineData( int.MinValue, int.MaxValue, -1.000000000466)]
[InlineData( int.MinValue, -int.MaxValue, 1.000000000466)]
public void Divide_ExtremeInput_CorrectCalculation(
int dividend, int divisor, decimal expectedQuotient)
{
// Arrange
// Act
decimal actualQuotient = Division.Divide(dividend,
divisor);
// Assert
Assert.Equal(expectedQuotient, actualQuotient, 12);
}
注意现在我们有Theory而不是Fact。这是 xUnit 声明单元测试方法是参数化的方式。此外,注意我们有四个InlineData属性;正如你可能会已经想到的,每个属性都对应一个测试用例。
我们的单元测试方法和InlineData属性有三个参数。在运行单元测试时,每个参数将映射到相同顺序的单元测试方法的参数。以下截图显示了InlineData属性中的每个参数如何对应到Divide_ExtremeInput_CorrectCalculation方法中的参数:

图 1.18 – InlineData 参数映射到装饰方法参数
对于断言,我们使用支持十进制精度的Equal方法的重载,如下面的代码片段所示:
static void Equal(decimal expected, decimal actual,
int precision)
运行测试,你会注意到测试资源管理器将这四个属性视为单独的测试,如下面的截图所示:

图 1.19 – VS 测试资源管理器显示分组测试
更多测试
为了简洁起见,鉴于本章是一个有限的介绍,我们没有探索所有可能的测试场景——例如,int.MaxValue/int.MaxValue、int.MinValue/int.MinValue、0/number 和 0/0。
所需测试的范围将在后面的章节中讨论,包括它们的优缺点。
在编写代码之前编写测试并不是每个开发者的喜好,并且一开始可能看起来不太直观,但你有完整的一本书来让你自己做出决定。在第五章《测试驱动开发详解》中,你将更深入地了解实现和最佳实践。
摘要
虽然本章旨在快速实现,但我相信你已经尝到了 TDD 的滋味,并掌握了一些技能,例如xUnit、测试资源管理器、先测试后编码、红/绿和一些约定。
首先,我们选择了简单的例子——当然——因此,我们没有涉及依赖注入(DI),也没有模拟或其他复杂的东西,因为刺激的内容将在下一章出现。所以,我希望这一章已经让你对本书的其余部分感到兴奋。
如果你像我第一次遇到 TDD 时一样,可能会想知道以下问题:为什么先进行测试? 这不是单元测试代码太多吗? 单元测试有效吗? 单元测试和 TDD 有什么区别? 我应该写多少个测试? 你可能还有其他问题——这些问题的答案将在你阅读本书的过程中逐渐揭晓,我保证我会尽可能清晰地给出答案。
在下一章中,我们将涉及一个名为 DI 的设计模式,这是使用 TDD(测试驱动开发)的必要要求。
进一步阅读
为了了解更多关于本章讨论的主题,你可以参考以下链接:
第二章:通过示例理解依赖注入
依赖注入(DI)是一种存在于每个现代架构中的软件设计模式。然而,你可能想知道这种模式是如何进入一本以测试驱动开发(TDD)为重点的书的第二章的。
DI 是一种具有几个我们在整本书中将要发现的优点的设计模式,但其核心优点是DI 为单元测试打开了应用程序。没有对这个模式有坚实的理解,我们就无法进行单元测试,而如果不能进行单元测试,那么根据定义,我们就无法实践 TDD。考虑到这一点,DI 的理解构成了第一部分,“开始和基础知识”以及第二部分,“使用 TDD 构建应用程序”的基础,这解释了早期引入的原因。
我们将构建一个应用程序,然后在学习概念的同时修改它以支持 DI,但本章中的思想将在整本书中重复和练习。
在本章中,您将探索以下主题:
-
天气预报应用程序(WFA)
-
理解依赖
-
介绍 DI
-
使用 DI 容器
到本章结束时,应用程序将通过实施必要的 DI 更改而准备好进行单元测试。您将对依赖关系有一个公平的理解,并将对重构代码以支持 DI 充满信心。您也将完成了编写第一个正确单元测试的一半。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch02
在那里,您将找到四个目录。每个目录都将是我们进展的快照。
WFA
在本章的整个过程中,我们将使用一个ASP.NET Web API应用程序作为我们的学习过程。我们将重构该应用程序中的所有代码以启用 DI。然后,在第三章,“开始单元测试”,我们将对重构后的应用程序应用单元测试。
当创建一个新的 ASP.NET Web API 应用程序时,它自带一个示例随机天气预报器。本章中的应用程序将基于原始的天气示例,并添加一个真实的天气预报功能到现有的随机功能中。我们将我们的应用程序称为 WFA。
第一步将是创建一个 WFA 应用程序并确保它正在运行。
创建一个示例天气预报
要创建一个示例应用程序,请将您的控制台导航到您想要创建此应用程序的目录,并执行以下命令:
md UqsWeather
cd UqsWeather
dotnet new sln
dotnet new webapi -o Uqs.Weather -f net6.0
dotnet sln add Uqs.Weather
以下代码将创建一个UqsWeather并将一个 ASP.NET Web API 项目添加到其中。这将产生与这个控制台窗口类似的输出:

图 2.1 – 通过命令行界面 (CLI) 创建天气应用程序的输出
要检查我们创建的内容,请转到目录并使用 VS 打开解决方案,你将看到以下内容:

图 2.2 – 在 VS 中打开的新创建的项目
这里有趣的是自动生成的示例文件:WeatherForecast Controller.cs 和 WeatherForecast.cs。
这是默认模板;我们还没有进行任何修改。检查到目前为止应用程序是否正确加载是有意义的。你可以运行应用程序,它将启动你的默认浏览器并显示 Swagger UI 界面。我们可以看到唯一的可用 GET WeatherForecast,如下面的截图所示:

图 2.3 – Swagger UI 显示可用的 GET API
要手动调用此 API 并检查它是否生成输出,从 Swagger UI 页面,展开 /WeatherForecast 右侧的下箭头。点击 Try it out。然后,点击 Execute。你将得到如下所示的响应:

图 2.4 – Swagger API 调用响应
你可以在 GitHub 章节目录下的 01-UqsWeather 目录中找到这个示例。现在,是时候通过添加真实预报功能使应用程序更加真实了。
添加真实天气预报
模板应用程序包含一个示例随机天气生成器。我决定给这个应用程序添加一些真实天气预报的功能。为此,我将使用一个名为 OpenWeather 的天气服务。OpenWeather 提供了一个免费的 RESTful API 天气服务(其中 REST 代表 REpresentational State Transfer),并将作为一个更真实的示例。
我还创建了一个公共 NuGet 包,用于提供本章内容,并作为 OpenWeather RESTful API 的客户端。因此,你不需要处理 REST API 调用,而是调用一个 C# 方法,它会在后台执行 RESTful API 调用。在接下来的章节中,我们将获取一个 API 密钥并编写 GetReal API。
获取 API 密钥
要能够从配套源代码运行应用程序或自己创建一个,你需要一个 API 密钥。你可以在 openweathermap.org 上注册并获取一个 API 密钥。注册后,你可以通过访问 My API keys 并点击 Generate 来生成密钥,类似于以下示例:

图 2.5 – 生成 API 密钥
一旦你获得了密钥,将其保存在你的 appsettings.json 文件中,如下所示:
{
"OpenWeather": {
"Key": "yourapikeygoeshere"
},
"Logging": {
…
API 密钥已生成。让我们获取一个客户端库来访问 API。
获取客户端 NuGet 包
有许多 OpenWeather API 客户端库;然而,我选择创建一个专门符合本章要求的库。包的代码及其测试方法在附录 2中讨论,高级模拟场景。如果你好奇并想查看源代码,你可以访问其 GitHub 仓库github.com/AdamTibi/OpenWeatherClient。
你可以通过 VS 中的AdamTibi.OpenWeather或通过.NET CLI 安装 NuGet 包,方法是进入项目目录并编写以下内容:
dotnet add package AdamTibi.OpenWeather
配置已完成,因此现在我们可以修改代码。
将感觉映射到温度
这里有一个简单的方法,将°C 的温度映射到一个描述它的单个单词:
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild",
"Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private string MapFeelToTemp(int temperatureC)
{
if (temperatureC <= 0) return Summaries.First();
int summariesIndex = (temperatureC / 5) + 1;
if (summariesIndex >= Summaries.Length) return
Summaries.Last();
return Summaries[summariesIndex];
}
对于0或更低的输出是Freezing,在0到5之间是Bracing,然后每5度就会改变一次。从45度开始,就是Scorching。不要只相信我的输出——我们将进行单元测试。想象一下如果我们没有这样做会怎样!
随机天气 API
我保留了随机天气 API,但我让它使用前面的MapFeelToTemp字符串,如下所示:
[HttpGet("GetRandomWeatherForecast")]
public IEnumerable<WeatherForecast> GetRandom()
{
WeatherForecast[] wfs = new
WeatherForecast[FORECAST_DAYS];
for(int i = 0;i < wfs.Length;i++)
{
var wf = wfs[i] = new WeatherForecast();
wf.Date = DateTime.Now.AddDays(i + 1);
wf.TemperatureC = Random.Shared.Next(-20, 55);
wf.Summary = MapFeelToTemp(wf.TemperatureC);
}
return wfs;
}
这是一个生成随机温度并对其做出总结的简单 API。我们正在生成FORECAST_DAYS = 5天的预测,从第二天开始。
运行此项目并点击 Swagger UI 输出将给我们以下结果:
[
{
"date": "2021-11-26T22:23:38.6987801+00:00",
"temperatureC": 30,
"temperatureF": 85,
"summary": "Hot"
},
{
"date": "2021-11-27T22:23:38.7001358+00:00",
"temperatureC": -15,
"temperatureF": 6,
"summary": "Freezing"
},
…
你可以看到输出是多么随机,因为第二天很热,但第三天就非常冷。
真实天气 API
真实天气 API 应该更有意义。这是新添加的 API:
[HttpGet("GetRealWeatherForecast")]
public async Task<IEnumerable<WeatherForecast>> GetReal()
{
…
string apiKey = _config["OpenWeather:Key"];
HttpClient httpClient = new HttpClient();
Client openWeatherClient =
new Client(apiKey, httpClient);
OneCallResponse res = await
openWeatherClient.OneCallAsync
(GREENWICH_LAT, GREENWICH_LON, new [] {
Excludes.Current, Excludes.Minutely,
Excludes.Hourly, Excludes.Alerts },
Units.Metric);
…
}
此方法创建一个HttpClient类,以便将其传递给OpenWeather的Client类。然后它获取 API 密钥并创建一个OpenWeather的Client类。为了限制我们的范围,这将只为格林威治,伦敦进行预测。
重要提示
之前的代码不够整洁,将在本章中稍后进行清理。如果你现在真的想了解原因,那就是在控制器中实例化(新建)HttpClient和Client类,这不是一个好的做法。
我们正在调用名为OpenWeather的 RESTful API 的OneCall。此 API 返回今天的天气并预测接下来的 6 天;这对于我们只需要接下来的 5 天来说很好。此 API 的 Swagger UI 输出如下所示:
[
{
"date": "2021-11-26T11:00:00Z",
"temperatureC": 8,
"temperatureF": 46,
"summary": "Chilly"
},
{
"date": "2021-11-27T11:00:00Z",
"temperatureC": 4,
"temperatureF": 39,
"summary": "Bracing"
},
…
通过例子解释概念是最好的方法,所以考虑这个测试问题,它将给你一个亲身体验 DI(依赖注入)的机会。
C 到 F 转换 API
为了让全世界团结起来并让每个人都开心,我们将添加另一个方法将°C 转换为°F。我们将在控制器上有一个名为ConvertCToF的 API,它看起来像这样:
[HttpGet("ConvertCToF")]
public double ConvertCToF(double c)
{
double f = c * (9d / 5d) + 32;
_logger.LogInformation("conversion requested");
return f;
}
此 API 将温度从°C 转换为°F,并记录每次请求此 API 的情况,用于统计目的。你可以像以前一样从 Swagger UI 调用此 API,或者从浏览器中这样调用它:
https://localhost:7218/WeatherForecast/ConvertCToF?c=27
输出将看起来像这样:

图 2.6 – 从浏览器执行 ConvertCToF API 的结果
这是一个统一建模语言(UML)图,显示了到目前为止我们所拥有的内容:

图 2.7 – 显示 WFA 应用的 UML 图
你可以在WeatherForecastController.cs中看到所有更改;它始终位于 GitHub 上的Ch02源代码中,在名为02-UqsWeather的目录下。
应用程序已准备好接受批评,现在我可以告诉你,你刚刚看到的代码是不可进行单元测试的。我们可以执行其他测试类别,但不能进行单元测试,尽管到本章结束时它将是可进行单元测试的。我邀请你打开项目在 VS 中跟随,因为我们将实现令人兴奋且重要的概念。
现在项目已准备好,我们需要做一些基本设置,列表中的第一项是理解依赖项。
理解依赖项
如果你的代码做了些有用的事情,那么你的代码很可能依赖于其他代码或另一个组件,而这个组件反过来又依赖于另一个组件。对依赖项术语的清晰理解应该会帮助你更好地掌握单元测试,并肯定有助于与同事进行更清晰的交流。
本节中的计划是使你熟悉依赖项的概念,这应该会使理解 DI 模式更容易。理解依赖项和 DI 是编写任何严肃单元测试的先决条件。接下来,我们将探讨依赖项是什么,尽管在单元测试方面,我们并不关心所有依赖项,因此我们将定义相关依赖项是什么。
在我们深入依赖项之前,让我们首先定义抽象类型和具体类型。
抽象类型和具体类型
为了让我们处于同一频道,我将定义将要使用的术语。
一个具体类是可以实例化的类;它可能像这样:
FileStream fileStream = new FileStream(…)
FileStream是一个具体类型,可以在代码中直接实例化和使用。
抽象类型可以是抽象类或接口。抽象类的例子有Stream、ControllerBase和HttpContext。接口的例子有IEnumerable、IDisposable和ILogger。
我将在书中过度使用这些术语,所以值得定义它们。
什么是依赖项?
首先,它不是什么:它不等于在 UML 中使用的相同术语。
在本书的上下文中,以及与其他开发者在单元测试边界内交谈时,可以这样定义:如果类 A 使用 类型 B,其中 B 是抽象类型或具体类,那么 A 依赖于 B。
术语 使用 可以缩小到以下内容:
-
B 被传递给 A 的构造函数。WFA 中的例子:记录器被传递给控制器的构造函数,这使得
ILogger<WeatherForecastController>成为一个依赖关系,如下所示:public WeatherForecastController( ILogger<WeatherForecastController> logger, …) -
B 被传递给 A 中的方法,如下所示:
public void DoSomething(B b) { … -
B 有一个静态方法,该方法从 A 的方法中调用。WFA 中的例子:
DateTime.Now在GetRandom中被调用,这使得DateTime成为一个依赖关系,如下所示:wf.Date = DateTime.Now.AddDays(i + 1); -
B 在任何 A 内部的地方被实例化,无论是在方法中、字段中还是在属性中。在以下示例中,
HttpClient在代码中被实例化:HttpClient httpClient = new HttpClient();
根据这个定义,以下所有内容都是WeatherForecast Controller的依赖关系:
-
Random -
DateTime -
Client -
HttpClient -
ILogger<WeatherForecastController> -
IConfiguration -
WeatherForecast
数据传输对象(DTOs)不被视为依赖关系,尽管它们看起来像具体的类,但它们充当从一地到另一地传输数据的载体。我们将在WeatherForecast 类依赖关系部分展示一个 DTO 的例子。
注意,record、record struct和struct通常遵循与 DTO 相同的概念。
我们将对跨越第一部分入门和基础和第二部分使用 TDD 构建应用程序的依赖关系进行更多分析。对于有经验的 TDD 实践者来说,发现依赖关系是第二本能。
依赖关系相关性
依赖关系使我们的类与代码外部的组件进行交互。在单元测试的上下文中,如果依赖关系有一个在触发时或导致其他与被测试类不太相关的行为时可能引起副作用的方法或属性,则该依赖关系与 DI 相关。
这是一个过载的定义,并不打算在这个阶段完全清晰。从现在开始直到第二部分的结束,我们将提供一些例子来展示何时依赖关系是相关的。
如果我们想要在测试时改变其行为,我们会关注定位依赖关系。如果_logger.LogInformation正在写入磁盘,我们有时会改变这种行为,尤其是在测试时。像往常一样,用例子来说明是最好的,因此在本节中,我们将展示多个示例并解释为什么它们是相关的。
记录依赖关系
考虑这个_logger字段:
private readonly ILogger<WeatherForecastController>
_logger;
在应用程序的生命周期中,_logger字段可能会被触发以写入日志。根据记录器的配置,它可能会在内存中、调试时的控制台、磁盘上的日志文件、数据库或云服务(例如在ConvertCToF方法中记录_logger字段时)中写入日志,如下所示:
_logger.LogInformation("conversion requested");
它是相关的,因为我们有一个副作用会扩展到系统中的其他组件,并且在稍后的单元测试阶段,我们希望消除这个副作用。
配置依赖关系
类中还有一个字段,即_config字段,如下所示:
private readonly IConfiguration _config;
_config 字段是必需的,用于从配置中获取 API 密钥。它通过控制器类的构造函数传递,类似于 _logger 字段。
在运行时 _config 可以根据配置加载配置;这不是字面意义上的双关语。您的配置可以位于云端、appsettings 或自定义格式中。我们可以在以下示例中看到这个依赖项的使用:
string apiKey = _config["OpenWeather:Key"];
这是相关的,因为我们需要通过配置来读取 API 密钥。访问配置也会产生副作用。
HTTP 依赖
在代码中深入挖掘,你会发现我们实例化了 HttpClient 并在代码中使用它:
HttpClient httpClient = new HttpClient();
很明显,我们依赖于 GetReal API,它被调用,它发出 HTTP 请求。
与日志和配置依赖项不同,其中依赖项是对抽象(IConfiguration 和 ILogging<>)的构建,httpClient 在代码中被实例化——这被称为硬或具体依赖。
我们确实关心在代码中实例化依赖项或从外部通过构造函数传递依赖项之间的区别。稍后我们会清楚为什么。
这是相关的,因为我们不希望在测试时依赖网络。
OpenWeather 客户端依赖
OpenWeather 客户端是一个依赖项的依赖项。它本身也是一个依赖项,并且它依赖于由 httpClient 表示的 HTTP 依赖项。您可以在以下代码片段中看到这一点:
Client openWeatherClient = new Client(apiKey, httpClient);
此外,这也是另一个具体的依赖项的例子,因为它是在行内实例化的。
这是相关的,因为我们不希望在测试时依赖 HTTP(或网络)。
时间依赖
考虑以下代码行:
wf.Date = DateTime.Now.AddDays(i + 1);
这里重要的是 Now 属性。Now 有代码会调用 Now 属性是静态的,正如我们在这里可以看到的:
public static DateTime Now { get; }
由于这是静态的,它将使 DI(依赖注入)的处理稍微困难一些,正如我们很快就会看到的。
这是相关的,因为我们希望在测试时有一个可预测的时间。获取当前时间不会导致一致的结果,因为时间是在变化的。
随机性依赖
这是一个依赖于算法生成随机性的例子:
wf.TemperatureC = Random.Shared.Next(-20, 55);
Next 方法也是一个静态方法,它在后台调用时间来生成种子;它还依赖于一个随机化算法。我们希望控制结果,以便我们可以对其进行测试。
这是相关的,因为我们希望输出是可预测的。
天气预报类依赖
我们将这个类实例化为 DTO,因为我们想将数据从我们的方法传输到客户端。这个数据结构将被序列化为JavaScript 对象表示法(JSON)。代码如下所示:
WeatherForecast[] wfs = new WeatherForecast[FORECAST_DAYS];
这不相关,因为这个对象不会产生副作用,它只是携带数据。
如果代码依赖于抽象并且对象没有在类中实例化(如前例中的控制器),那么这通常是好的。如果代码依赖于在类中实例化的具体类,那么我们就不是遵循最佳实践,因为我们违反了一个好的面向对象编程(OOP)实践:依赖抽象,而非具体。这将是我们的下一个主题。
依赖抽象,而非具体
这个标题是面向对象编程最佳实践中的流行建议。这个建议适用于两种情况:方法签名和方法内部的代码。我们将在本节中探讨这两种情况。
方法签名中的抽象参数
在设计方法,包括类构造函数时,建议检查你是否可以接受一个抽象类型而不是一个具体类型。像往常一样,让我们用例子来解释这一点。
以一个抽象类的例子来说明,我们可以从.NET 中著名的Stream类,如下代码片段所示:
public abstract class Stream : …
Stream对象代表一系列字节,但该类不关心字节物理来源——无论是来自文件还是来自内存或其他。这就是将其作为抽象类的原因。
我们以FileStream为例,它继承自Stream,作为一个具体类的示例,如下所示:
public class FileStream : Stream
FileStream理解从磁盘文件读取字节流的要求。
我们还有MemoryStream,它继承自Stream,作为另一个具体类的示例,如下所示:
public class MemoryStream : Stream
下面是一个 UML 图来总结这些关系:

图 2.8 – 流及其子类
将Stream作为抽象类打开了System.Text.Json.JsonSerializer类接受类型为Stream的参数的道路:
public static void Serialize (Stream utf8Json, object?
value, …);
此方法将提供的值转换为Stream类。
因为这个方法不关心Stream类如何处理底层的物理持久性,所以它接受Stream父抽象类作为参数。如果没有抽象,那么你将会有多个相同方法的过载。每个过载都接受不同的流类型,如下所示(这些过载不存在;它们是假设的):
public static void Serialize (FileStream utf8Json, …);
public static void Serialize (MemoryStream utf8Json, …);
public static void Serialize (SqlFileStream utf8Json, …);
public static void Serialize (BufferedStream utf8Json, …);
More Stream overloads…
这是一个接受抽象类型作为方法参数的好处示例。这里还有另一个例子。考虑以下代码:
public static int Sum(int[] elements)
{
int sum = 0;
foreach (int e in elements) sum += e;
return sum;
}
此方法接收一个数组并返回其元素的总和。乍一看,方法的签名看起来不错,但如果你仔细想想,这个方法强制调用者在使用方法之前将任何集合转换为数组,这是一个不必要的转换,并且由于此方法不依赖于数组的特定功能,所以是一种性能浪费。它只是在执行一个foreach结构,这意味着它按顺序访问数组元素。它真的需要接受一个数组吗?
将签名参数转换为IEnumerable<int>这种抽象类型,将允许此方法接受相当数量的具体类,如下所示:
public static int Sum(IEnumerable<int> elements)
你最初只能用int[]数组调用此方法;现在,它可以传递给实现IEnumerable<int>的任何对象,而且有很多。以下是一些例子:
int[] array = new int[] { 1, 2 };
List<int> list = new List<int> { 1, 2 };
Queue<int> queue = new Queue<int>();
queue.Enqueue(1);
queue.Enqueue(2);
// More collections
Sum(array);
Sum(list); // new benefit
Sum(queue); // new benefit
回到 WFA 应用程序,我们的控制器构造函数已经做得很好,因为它依赖于抽象,如下面的代码片段所示:
public WeatherForecastController(
ILogger<WeatherForecastController> logger,
IConfiguration config)
总是考虑满足尽可能使你的方法开放的公共分母抽象类型。
直接实例化依赖项
我们刚刚讨论了在可能的情况下在我们的方法签名中使用抽象,这减少了耦合并增加了方法的可用性。本节将此建议扩展到代码中。
如果我们在代码中直接实例化类,我们就依赖于具体对象。如果我们依赖于具体对象,那么我们无法在运行时改变它们的行为。让我们以我们的 WFA 应用程序为例,我们在方法中实例化Client类,就像以下代码行所示:
Client openWeatherClient = new Client(apiKey, httpClient);
然后,每次我们使用openWeatherClient对象,例如调用OneCallAsync方法时,我们都会在网络上向OpenWeather端的 RESTful API 发送 HTTP 请求。这对于生产代码来说是好的,但不适合测试代码;当我们测试时,我们希望消除这种行为。
隔离
在这种情况下,我们可以避免 HTTP 调用,并在测试期间使用隔离框架来解决这个问题。然而,这只是一个最后的手段。我们将在第三章,“开始单元测试”中解释隔离框架是什么。
在测试代码时,我们不希望它出于许多原因触发 HTTP 请求,包括以下原因:
-
我们每段时间可以进行的调用次数有限——有一个配额。
-
我们的测试环境位于防火墙后面,该防火墙禁止出站流量。
-
网络另一端的 REST 服务暂时关闭,因此我们将得到一个假阴性结果,即我们的测试失败了。
-
通过互联网调用服务比处理 CPU 和内存要慢。
你能看出我们的方向吗?代码是可行的,但它不能在独立于 HTTP 调用的环境中进行测试。
重要提示
一些测试类别应该触发 HTTP 请求并到达另一端,例如集成测试。在先前的上下文中,我指的是验证业务逻辑而不测试连接性的测试——其中之一是单元测试。
如果我们要对某个功能进行单元测试,实例化具体类将不起作用。我们在单元测试期间想要做的是检查是否进行了 虚假尝试 来触发调用,但实际上并没有执行,这就足够了。到目前为止,我们得到的结论是,在代码中创建具体类与 DI 不兼容,因此也与单元测试不兼容。
避免在业务逻辑中实例化类的根本解决方案是依赖注入(DI),我们很快就会看到。
最佳实践回顾
我们在“依赖抽象,而非具体”部分的讨论归结为以下两个 应该做 和 不应该做 的例子。让我们从不好的或 不应该做 的例子开始,如下所示:
public class BadClass
{
public BadClass() {}
public void DoSometing()
{
MyConcreteType t = new MyConcreteType();
t.UseADependency();
}
}
这里是一个等效的好类示例:
public class GoodClass
{
private readonly IMyClass _myClass;
public GoodClass(IMyClass myClass)
{ _myClass = myClass; }
public void DoSometing()
{
_myClass.UseADependency();
}
public void DoSometingElse(SecondClass second)
{
second.UseAnotherDependency();
}
}
这里有一些好的实践:
-
将抽象作为参数鼓励解耦,并使方法能够接受更多类型。
-
依赖抽象允许在不更改类中的代码的情况下更改对象的行为。
你可能会问一个问题:如果我在运行时没有实例化传递给构造函数或方法的对象,那么是谁实例化的呢?肯定在某个环节,某个进程已经实例化了我的依赖并将它们传递给我的类。 这个问题的答案可以在下一节找到。
介绍依赖注入
当我第一次学习如何在代码中实现依赖注入时,我感到一种狂喜,就像我发现了软件工程中的秘密一样;它就像 代码魔法。在前面的章节中,我们已经探讨了依赖关系,现在,我们即将发现将这些依赖注入到我们的类中。下一步是解释什么是 DI,并使用 WFA 应用程序的实际示例来确保你在各种场景中进行实验。介绍 DI 的最好方式是通过一个熟悉的例子。
依赖注入的第一个例子
依赖注入(DI)在现代 .NET 代码中无处不在。实际上,我们就在 ASP.NET 模板代码中找到了一个例子:
public WeatherForecastController(
ILogger<WeatherForecastController> logger)
{
_logger = logger;
当创建控制器的新实例时,将依赖项 logger 对象注入到控制器中。在控制器中没有任何地方是实例化 logger 类的。它已经被注入到控制器的构造函数中。
在这个上下文中,“注入”意味着 ASP.NET 框架发现了一个需要实例化这个控制器的传入请求。框架意识到要创建 WeatherForecastController 的新实例,它需要创建一个实现了 ILogger<WeatherForecastController> 的具体类的实例,以执行类似以下操作:
ILogger<WeatherForecastController> logger = new
Logger<WeatherForecastController>(…);
var controller = new WeatherForecastController(logger);
控制器的构造函数需要一个实现了 ILogger<WeatherForecastController> 的具体类的实例,并且框架解析出 Logger<> 实现了 ILogger<>,因此它可以作为控制器构造的参数使用。
它是如何解决的?我们将在 DI 容器中学习这一点;现在重要的是,它知道如何实例化控制器类。
现在是时候给我们的剧本中的每一个主题起一个与 DI 相关的名字,如下所示:
-
DI 容器:管理注入的软件库
-
ILogger<>派生对象) -
客户端:请求服务的类(在上一个例子中的控制器)
-
激活:实例化客户端的过程
-
解决方案:DI 容器找到激活客户端所需的服务
测试一个 API
让我们通过一个例子更深入地了解 DI。考虑这个测试问题,它将给你亲身体验 DI 是什么的第一手经验。考虑我们在 WFA 应用程序中之前创建的ConvertCToF方法。
我们想对这个方法进行一些测试,以验证温度转换是否准确完成。我们得到了一些°C 和相应的°F 的例子,如下所示:
-
-1.0 C = 30.20 F
-
1.2 C = 34.16 F
为了满足测试,我们想使用一个老式的控制台应用程序,如果转换不匹配示例,它将抛出异常。
你可以通过 VS GUI 添加控制台应用程序,或者你可以从解决方案目录执行以下行:
dotnet new console -o Uqs.Weather.TestRunner
dotnet sln add Uqs.Weather.TestRunner
dotnet add Uqs.Weather.TestRunner reference Uqs.Weather
这将在现有解决方案中添加一个新的控制台应用程序Uqs.Weather.TestRunner,并引用现有的 ASP.NET Web API 应用程序。在 VS 中,将以下代码添加到控制台应用程序的Program.cs文件中:
using Microsoft.Extensions.Logging;
using Uqs.Weather.Controllers;
var logger = new Logger<WeatherForecastController>(null);
//fails
var controller = new WeatherForecastController(logger,
null!);
double f1 = controller.ConvertCToF(-1.0);
if (f1 != 30.20d) throw new Exception("Invalid");
double f2 = controller.ConvertCToF(1.2);
if (f2 != 34.16d) throw new Exception("Invalid");
Console.WriteLine("Test Passed");
当前格式的代码无法运行,因为它在var logger行失败。我们稍后会修复这个问题,但首先让我们分析一下代码。代码以我们实例化任何.NET 中的类的方式实例化了一个控制器,然后调用ConvertCToF方法并尝试不同的值。如果所有值都通过,它将打印测试通过;否则,它将抛出一个异常。
要实例化一个Logger<>对象,我们需要将其构造函数传递一个ILoggerFactory类型的对象。如果你传递null,它将在运行时失败。此外,ILoggerFactory的具体实现实例不应该手动实例化,除非你正在集成日志记录框架或处理特殊情况,而测试不是特殊情况!简而言之,我们无法轻易做到这一点。
如果我们尝试向控制器的构造函数传递两个 null 值,并且忽略创建一个Logger<>对象,就像这样:
var controller = new WeatherForecastController(null, null);
问题在于,如果你传递一个null值,控制器中的_logger对象将是 null,你的代码将在这一行失败,并出现著名的NullReferenceException异常,如下所示:
_logger.LogInformation("conversion requested");
我们真正想要的是只是实例化控制器。我们不是在测试记录器;我们希望传递给构造函数任何可以创建控制器对象的东西,但记录器挡在了我们的路上。结果发现,Microsoft 有一个名为NullLogger<>的类,它正是这样做的——让路!Microsoft 的文档中这样描述:“最小化记录器,什么都不做”。
在这个类的启发下,代码的前几行将看起来像这样:
var logger = NullLogger<WeatherForecastController>
.Instance;
var controller = new WeatherForecastController(logger, …);
我们通过Instance字段获取到NullLogger<>的引用。当我们调用_logger.LogInformation时,不会发生任何事,这符合我们的需求。如果我们现在运行这个控制台应用程序,我们将得到一个测试通过的消息。
重要提示
通过控制台应用程序进行测试并不是最佳实践。同样,抛出异常和写入消息也不是报告通过和失败的测试的理想方式。正确的方法将在下一章中介绍。
控制器的构造函数接受一个ILogger<>对象,这给了我们传递一个NullLogger<>对象作为后者的灵活性,因为它实现了ILogger<>接口,如下所示:
public class NullLogger<T> : Microsoft.Extensions.Logging
.ILogger<T>
日志类的 UML 图看起来如下:

图 2.9 – Logger<>, NullLogger<>, 和 ILogger<> 的 UML 图
到目前为止,分析我们所做的是值得的。这是我们取得的成绩:
-
在运行时(当 API 启动时),
Logger<>被注入到控制器中,并应该按预期写入日志。 -
在测试时,我们对日志活动不感兴趣;我们正在测试另一个场景,所以我们传递了
NullLogger<>。 -
我们被允许向
ILogger<>注入不同的类型,因为ILogger<>是一个接口,它是一个抽象。如果我们的构造函数期望一个Logger<>类型(没有I的具体类型),我们就无法做到这一点。
在第一种情况下,是DI 容器在运行时注入了对象。在第二种情况下,这是我们手动注入一个不同的记录器进行测试。以下截图中的注释代码展示了本节内容的总结:

图 2.10 – 显示测试时间和运行时 DI 的注释代码
结论是,如果我们的参数使用抽象类型,如接口、ILogger<>类型接口或抽象类,我们可以使我们的类更具可重用性,从而可以利用 DI。
LogInformation方法根据注入的对象改变行为,因此它充当了一个接口。这自然地引导我们进入下一节关于接口的内容。
什么是接口?
作为英语中的一个术语,接口是两块布料缝合在一起的地方。在 DI 上下文中,这个术语类似于代码中我们可以改变行为而不显式更改代码的区域。我们可以指向我们之前转换方法中的例子,如下所示:
public double ConvertCToF(double c)
{
double f = c * (9d / 5d) + 32;
_logger.LogInformation("conversion requested");
return f;
}
以LogInformation方法为例。我们希望这个方法能够写入某个生产工具,但在测试时,我们希望它什么都不做(如果我们的测试场景不是关于日志记录)。我们想要测试其他功能,但_logger.LogInformation挡在了我们的路上,试图写入某个地方,因此我们希望改变它的行为。
LogInformation是一个接口,因为行为可以在这里改变。从上一节中,如果我们向类注入一个Logger<>对象,那么LogInformation将按一种方式表现,如果我们注入NullLogger<>,它将按另一种方式表现。
控制反转
你经常会听到控制反转(IoC)这个术语用来表示 DI。你也可能会听到 IoC 容器,这也意味着 DI 容器。从实用主义的角度来看,你不需要担心这些术语在意义上的差异。实践者对 IoC 和它与 DI 的关系有不同的定义。只需搜索其中一个术语与另一个术语,你会在论坛上找到充满矛盾的定义。
这里是实践者普遍认同的要点:
-
IoC 是将事件流从软件到用户界面(UI)或相反方向反转。
-
DI 是 IoC 的一种形式。
DI 是最流行且最现代的术语。术语 IoC 来自不同的时代,更通用,并且实用性较低,所以我建议使用术语 DI。
在所有这些例子、最佳实践和定义之后,我把最好的留到了最后,这就是本章的实践部分。这是你可以如何利用所有前面的文献来编写有用代码的方法。
使用 DI 容器
DI 容器是一个库,它将服务注入客户端。DI 容器提供了除了注入依赖项之外的其他功能,例如以下内容:
-
注册需要注入的类(注册服务)
-
实现服务需要如何实例化
-
实例化已经注册的内容
-
管理创建的服务生命周期
让我们用一个来自上一段代码的例子来明确 DI 容器的角色。我们有logger服务被注入,但谁负责这个?
有一个名为Microsoft.Extensions.DependencyInjection的 DI 容器,它将注入_logger。这发生在Program.cs的第一行,如图所示:
var builder = WebApplication.CreateBuilder(args);
上一个方法调用注册了一个默认的日志记录器。不幸的是,虽然我们可以在 .NET 源代码中看到代码,但在我们的 Program.cs 源代码中并不明显。事实上,上一行注册了许多其他服务。
通过在Program.cs的上一行之后添加一行用于实验,我们可以看到创建了多少注册的服务:
int servicesCount = builder.Services.Count;
这将给我们 82 个服务。其中一些服务与日志相关活动有关。所以,如果你想查看它们,你可以在上一行之后直接添加这一行:
var logServices = builder.Services.Where(_ =>
x.ServiceType.Name.Contains("Log")).ToArray();
你可以看到这里我们正在筛选任何名称中包含Log字样的服务。如果你在这行代码后设置断点并转到 VS 的logServices,你可以看到所有注册的日志相关服务的快照,如下面的截图所示:

图 2.11 – 显示已注册日志相关服务的即时窗口
截图显示我们有 10 个注册的日志相关服务。在运行时为我们注入的是第二个(索引号 1)。
注意
根据你的 ASP.NET 版本,你可能会得到一个不同的预注册服务列表。
我们将更改控制器中的实现,将所有内容移动到依赖注入,并尝试编写 DI-ready 代码的各种场景。
容器角色
容器活动是由 DI 容器在后台执行的。容器参与启动你的应用程序中的类,如下面的截图所示:

图 2.12 – 容器在行动(伪代码)
DI 容器框的代码是伪代码。它试图总结 DI 如何从已注册的服务列表中解析客户端所需的服务。然后,DI 激活客户端并将其传递给服务。所有这些都是在运行时发生的。
注册是我们将在许多示例中探索的活动。在这个场景中,有一个指令说明每当客户端请求一个ILogger<>对象时,就用Logger<>类型的具体类来替换它。
重要的是要注意,虽然客户端正在请求一个接口,但 DI 之前已经指示了如何为这个抽象构造一个具体的类;DI 容器之前就知道要构造一个Ilogger<>对象,它需要初始化一个Logger<>对象。
第三方容器
到目前为止,我们一直在使用一个内置的 DI 容器,它与新的 ASP.NET 项目自动连接,这就是Microsoft.Extensions.DependencyInjection Microsoft DI 容器,但这并不是.NET 6 可用的唯一 DI 容器——还有其他第三方选项。
近年来,微软开发了一个 DI 容器。第三方容器逐渐失去了人气,转而使用.NET 自带的一个。此外,一些框架在.NET 5 的引入时并未跃进。如今,随着.NET 6 的推出,剩下的强大容器是Autofac和StructureMap。还有其他支持.NET 6 的容器,但它们并不那么受欢迎。
如果你熟悉单元测试并且想要更多在Microsoft.Extensions.DependencyInjection中不支持的功能,那么可以看看其他框架,如 Autofac。但对于非单体、中等规模的项目,我建议坚持使用微软的,因为它得到了很好的支持,并且有大量的第三方插件组件。你总是可以在以后阶段切换到另一个框架。我的建议是不要浪费宝贵的时间选择 DI 容器。从微软的版本开始,直到你的需求超过它。
服务生命周期
当一个服务注册为传递给客户端时,DI 容器必须决定服务的生命周期。生命周期是从服务创建到释放以进行垃圾回收或销毁的时间间隔。
微软 DI 容器在注册服务时可以指定三个主要生命周期:临时、单例和作用域生命周期作用域。
注意,如果服务实现了IDisposable接口,当服务释放时将调用Dispose方法。当服务释放时,如果它有依赖项,它们也会被释放和销毁。接下来,我们将探讨三个主要生命周期。
临时生命周期
临时服务每次注入或请求时都会创建。容器为每个请求简单地创建一个新的实例。
这在不需要担心线程安全或服务状态修改(由另一个请求对象引起)方面是好的。但是,为每个请求创建一个对象会有不良的性能影响,尤其是在服务需求高的时候,激活它可能并不便宜。
你将在稍后的重构 DI部分看到一个临时服务的例子。
单例生命周期
单例服务在第一个客户端请求时创建一次,并在应用程序终止时释放。相同的激活服务将被传递给所有请求者。
这是最有效率的生命周期,因为对象只创建一次,但这也是最危险的,因为单例服务应该允许并发访问,这意味着它需要是线程安全的。
你将在稍后的重构 DI部分看到一个单例服务的例子。
作用域生命周期
作用域服务在每个 HTTP 请求中创建一次。它们从 HTTP 请求的开始活到 HTTP 响应的结束,并且它们将在客户端之间共享。
如果你想让一个服务被几个客户端使用,并且服务只适用于单个请求,这是好的。
与瞬态和单例生命周期相比,这种生命周期最不受欢迎。在性能方面,它位于瞬态和单例生命周期之间。在给定时间内,只有一个线程执行每个客户端请求,并且因为每个请求都得到一个单独的 DI 范围,所以你不必担心线程安全。
使用范围服务的流行例子是使用Entity Framework 的(EF 的)DB 上下文对象作为范围,这允许请求共享相同的数据,并在需要时在客户端之间缓存数据。
这里还有一个例子。假设你有一个允许客户端记录日志的服务,但它只会在 HTTP 请求结束后将数据从内存刷新到目标媒体(例如,保存到数据库)。忽略其他条件,这可能是范围生命周期的候选者。
我们将在第九章中看到一个范围生命周期的示例,使用 Entity Framework 和关系型数据库构建预约预订应用。
选择生命周期
如果你的关注点是性能,那么考虑单例。然后,下一步是检查服务是否线程安全,无论是通过阅读其文档还是进行其他类型的调查。
然后,如果相关,就降到范围级别,然后降到瞬态。始终选择瞬态是最安全的选项——如果有疑问,那么就选择瞬态!
重要提示
任何被注入到单例中的类都将成为单例,无论注入对象的生命周期如何。
容器工作流程
在我们查看服务注册和生命周期的示例之前,这是一个很好的时机来概括我们对 DI 容器的理解,并查看 DI 激活过程的流程图:

图 2.13 – DI 容器的流程
在这个图中,很明显,当激活一个类时,DI 容器有两个主要关注点,即注册和生命周期。
为 DI 重构
如果你正确地做了 DI,那么在实现单元测试方面你已经完成了一半。在编写单元测试时,你会考虑如何使一切准备好 DI。
有一些因素将决定你的服务应该如何注入,具体如下所述:
-
我的接口是否属于一个抽象方法?换句话说,问题中的方法是否存在于一个抽象中?这是我们在前面看到的
ILogger.LogInformation方法的情况,但我们将在这个注入 OpenWeather 客户端部分更详细地介绍这个场景。 -
我的接口是否是一个静态方法?这将在注入 DateTime和注入随机生成器部分中介绍。
注入 OpenWeather 客户端
一个有问题的行是WeatherForecastController.cs中的Client类实例化,如图所示:
string apiKey = _config["OpenWeather:Key"];
HttpClient httpClient = new HttpClient();
Client openWeatherClient = new Client(apiKey, httpClient);
OneCallResponse res =
await openWeatherClient.OneCallAsync(…)
访问_config的唯一目的是获取Client的 API 密钥,实例化HttpClient的唯一目的是将其传递给Client的构造函数。因此,如果我们注入openWeatherClient,则前两行将不再需要。
我们从待注入的类中使用哪种方法或属性?通过查看代码,答案是OneCallAsync。那么,在Client的层次结构中,具有此成员的最高类型(一个类、一个抽象类或一个接口)是什么?要做到这一点,请按住Ctrl按钮并点击 VS 中的类名,你将发现Client实现了IClient,如图所示:
public class Client : IClient
然后,按住Ctrl并点击IClient,你将找到以下接口:
public interface IClient
{
Task<OneCallResponse> OneCallAsync(decimal latitude,
decimal longitude, IEnumerable<Excludes> excludes,
Units unit);
}
显然,我的实现可以依赖于IClient而不是Client。
在控制器构造函数中,添加IClient并添加_client作为字段,如下所示:
private readonly IClient _client;
public WeatherForecastController(IClient client, …
{
_client = client;
…
最后一步是对这两行进行以下修改:
Client openWeatherClient = new Client(apiKey, httpClient);
OneCallResponse res =
await openWeatherClient.OneCallAsync(…);
删除第一行,因为我们不再实例化Client,并将你的第二行修改为使用_client而不是之前的openWeatherClient。这将导致以下代码:
OneCallResponse res = await _client.OneCallAsync(…);
我们已经对控制器进行了所有修改。剩下的是在 DI 容器中注册如何为控制器构造函数注入匹配IClient的对象。让我们以当前状态运行项目,我们将得到以下错误:
System.InvalidOperationException: Unable to resolve service
for type IClient' while attempting to activate
'WeatherForecastController'
DI 容器试图寻找一个实现IClient的具体类,以便它可以创建它并将其传递给WeatherForecastController的构造函数。我们知道有一个名为Client的具体类实现了IClient,但我们还没有告诉 DI 容器。
为了使 DI 容器注册一个服务,它需要两个信息位,如下所示:
-
如何创建所需的服务?
-
创建的服务生命周期是什么?
第 1 点的答案是,每当请求IClient时,我们需要创建一个Client实例。
第 2 点比较复杂。Client是一个在线有文档的第三方类。第一步是查看文档以查看它是否有推荐的寿命,在这种情况下,Client的文档指定Singleton为推荐的。在其他没有文档的情况下,我们必须以其他方式找出它。我们稍后会提供更多示例。
要注册我们的依赖项,在Program.cs文件中,查找由Add services to the container模板提供的注释,并在其下方添加你的代码,如下所示:
// Add services to the container.
builder.Services.AddSingleton<IClient>(_ => {
string apiKey =
builder.Configuration["OpenWeather:Key"];
HttpClient httpClient = new HttpClient();
return new Client(apiKey, httpClient);
});
在这里,我们以与之前相同的方式构造Client。一旦Client首次请求,每个应用程序将只创建一个实例,并且所有客户端在请求时将提供相同的实例。
现在,我们已经完成了GetReal方法所需的所有依赖项的 DI,让我们解决GetRandom方法中的Now依赖项。
注入 DateTime
在我们的GetRandom方法中,我们使用DateTime,并且注入它有些棘手。让我们看看代码中DateTime类的使用情况。我们使用以下内容:
-
AddDays方法 -
Now属性,它返回一个DateTime对象
所有这些都在一行代码中清晰地展示出来,如下所示:
wf.Date = DateTime.Now.AddDays(i + 1);
AddDays方法是一个依赖于天数算术计算的方法,可以通过查看 GitHub 上的DateTime源代码来验证,在github.com/microsoft/referencesource/blob/master/mscorlib/system/datetime.cs。
我们不必担心注入它,因为它没有达到外部依赖;它只是执行一些 C#代码,或者我们可能想要注入它来控制AddDays方法是如何计算的。在我们的例子中,注入AddDays不是必需的。
第二点是Now属性。如果我们编写一个涉及测试Now值的单元测试,我们希望将其冻结到一个常量值以便测试。在这个阶段,冻结它的画面可能还不清楚,但在下一章单元测试GetRandom时会更清晰。
我们需要提供一个注入的Now属性,但Now是一个静态属性,正如我们在这里看到的:
public static DateTime Now
静态属性(和方法)不遵循与实例属性相同的多态原则。因此,我们需要找出一种不同于之前的方法来注入Now。
下面的代码展示了如何以适合多态工作的方式准备Now。创建一个如下所示的接口作为抽象:
public interface INowWrapper
{
DateTime Now { get; }
}
我们将使我们的代码依赖于这个抽象类型。此外,我们还需要提供一个具体NowWrapper类的实现,所以我们的代码看起来就像这样:
public class NowWrapper : INowWrapper
{
public DateTime Now => DateTime.Now;
}
我在项目中的一个名为Wrappers的目录下添加了两个文件。我在其中添加了INowWrapper.cs和NowWrapper.cs。
Wrapper 和 Provider
一些开发者喜欢为这类类型添加Wrapper后缀,而其他人则喜欢使用Provider后缀,例如NowProvider。我不喜欢使用Provider这个名字,因为它已经是一个设计模式,可能会造成误导。我的建议是选择一种约定并保持一致性。
通常,当我们为注入注册一个非具体类型时,我们需要考虑两个要点,如下所示:
-
如何创建所需的服务?
-
创建的服务生命周期是什么?
第一点很容易——我们只需实例化NowWrapper类。第二点取决于DateTime.Now原始属性。由于我知道这是一个可能同时有多个请求击中我的静态属性的 Web 环境,我会首先检查流行的.NET 线程安全主题。换句话说,如果这个属性被多个线程同时访问,会不会导致不确定的行为?
DateTime的静态成员,包括Now属性,都是考虑到线程安全性而编写的,因此同时调用Now不应导致不确定的行为。
在这种情况下,我可以将我的 DI 作为单例。让我们注册INowWrapper以进行注入。与之前的例子一样,将INowWrapper添加到控制器构造函数中,如下所示:
public WeatherForecastController(, INowWrapper nowWrapper, )
{
_nowWrapper = nowWrapper;
…
将DateTime.Now替换为_nowWrapper.Now,如下所示:
wf.Date = _nowWrapper.Now.AddDays(i + 1);
最后,在Program.cs文件中注册您的依赖项,使用以下代码:
builder.Services.AddSingleton<INowWrapper>(_ =>
new NowWrapper());
这意味着当第一次请求INowWrapper实例时,DI 容器将实例化它,并保留其整个应用程序的生命周期。
注入随机生成器
随机数生成器的设计就是不可预测的;否则,它就不会是随机的!如果它没有 DI 注入,那么在单元测试中就会有问题,因为单元测试应该针对一个固定的(确定的)值进行测试。让我们看看这里的问题行:
wf.TemperatureC = Random.Shared.Next(-20, 55);
Shared是一个静态方法,所以我们有与之前任务中Now相同的问题。首先,我们需要确定线程安全性。在Next文档中没有明确提到它是否是线程安全的;相反,网上的一些说法称它不是线程安全的。因此,这里最安全的选项是假设它不是线程安全的。在这里,我们可以包装整个类或特定方法。我将选择包装整个类,以防我们以后需要使用Random类中的另一个方法。让我们编写我们的接口,如下所示:
public interface IRandomWrapper
{
int Next(int minValue, int maxValue);
}
在这里,我们有具体实现它的类:
public class RandomWrapper : IRandomWrapper
{
private readonly Random _random = Random.Shared;
public int Next(int minValue, int maxValue)
=> _random.Next(minValue, maxValue);
}
按照惯例将其添加到控制器构造函数中,并用此代码替换GetRandom中的代码:
wf.TemperatureC = _randomWrapper.Next(-20, 55);
我在类中稍微改变了行为;最初,每次我们调用Next时都会创建一个新的Random实例,但现在它为每个请求的类创建一个_randomWrapper。
由于我们的Next类实现依赖于线程不安全的_random.Next,因此我们的类也不是线程安全的。因此,在注入时,我们不能将其作为单例注入;我们必须将其作为瞬态注入,因此我们的Program.cs代码如下所示:
builder.Services.AddTransient<IRandomWrapper>(_ =>
new RandomWrapper());
这可能作为一个AddScoped注册方法有效,但文档不足,我无法做出决定,而瞬态总是最安全的。
现在,您可以运行应用程序,并通过 Swagger UI 执行两个 API,以确保一切按预期工作。
我们所做的 DI 更改都在 GitHub 上的Ch02源代码中,位于名为03-UqsWeather的目录下。
逼真的 DI 场景
使用依赖注入(DI)最常见的情况是与单元测试结合使用,尽管我也见过它在其他地方被用来在运行时改变某个组件的行为。考虑以下两种情况:一种是基于配置更改系统功能,另一种是针对不同的托管环境更改系统行为。让我们考虑下一个例子,即负载测试我们的 WFA 应用程序。
使用 DI 作为负载测试示例
对于关键系统,一个常见的非功能性需求(NFR)是负载测试。负载测试是对系统进行人工调用模拟以测量其处理高并发调用量的能力。对于我们的 WFA,负载测试看起来会是这样:

图 2.14 – 负载测试下的 WFA
负载测试框架将通过向 API 发出预定的调用数量来启动测试,并测量响应时间和失败次数。反过来,API 将对它们的依赖项施加负载。
完整的 WFA 可能有多重依赖项,但在这个例子中,我们特别感兴趣的是我们在后台调用的OpenWeather API。如果我们要对 WFA 应用程序进行负载测试,我们将设计性地向OpenWeather发出大量的调用,这不应该是情况,有很多原因。以下是一些:
-
消耗分配的调用配额数量
-
通过你的系统对他们的系统进行负载测试的合同协议
-
在短时间内因调用过多而被禁止
-
伦理原因,因为这可能会影响他们的整体服务质量
除非你的系统需要与第三方连接进行负载测试,并且你已经与第三方达成协议这样做,否则我不会这样做。
我们能做些什么来绕过这个问题,并在不调用OpenWeather的情况下进行负载测试?
一种解决方案是在 WFA 中添加一个配置键。当这个键为true时,我们希望所有应用程序中对OpenWeather的调用都返回一个存根响应(预定义响应)。关于模拟、存根、存根和伪造的更多内容将在下一章讨论。现在,我们将这种类型的响应称为存根响应。
启用 OpenWeather 存根响应
让我们启用一个代表 OpenWeather 的存根响应。我们从哪里开始?我会直接寻找导致调用 OpenWeather 的缝隙。正如这里所示,它在我们的WeatherForecastController类中:
OneCallResponse res = await _client.OneCallAsync(…)
我们需要做的是保持之前的代码不变,但通过不通过网络而是返回一些保存的值来改变这个方法的行为,当处于负载测试之下。以下是实现这一目标的计划:
-
添加一个配置来表示负载测试。
-
添加一个存根响应类。
-
注册一个基于配置的响应交换条件。
添加配置
我们希望默认情况下配置是关闭的,除非我们明确将其设置为开启。在你的appsettings.json文件中,添加以下代码:
"LoadTest": {
"IsActive" : false
}, …
在我们的appsettings.Development.json文件中,添加相同的配置,但将其设置为true。当你本地加载应用程序时,这应该会显示为true。
添加存根类
OneCallAsync 是 IClient 接口上的一个方法。如果您查看代码,我们会将 client 对象,即 _client,作为参数传递给构造函数。在这里我们可以做一些魔法——我们需要将我们的 IClient 模拟实现传递给构造函数,然后找出一种方法通过构造函数传递它。
在您的项目根目录下添加一个名为 ClientStub 的类,以保存我们模拟的 IClient 接口实现,如下所示:
public class ClientStub : IClient
{
public Task<OneCallResponse> OneCallAsync(
decimal latitude, decimal longitude,
IEnumerable<Excludes> excludes, Units unit)
{
const int DAYS = 7;
OneCallResponse res = new OneCallResponse();
res.Daily = new Daily[DAYS];
DateTime now = DateTime.Now;
for (int i = 0; i < DAYS; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = now.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day =
Random.Shared.Next(-20, 55);
}
return Task.FromResult(res);
}
}
IClient 定义在 OpenWeather 客户端的 NuGet 包中。它有一个实现 OneCallAsync 的方法。我查找了使用的属性并生成了一个 7 天的虚假预报。请注意,您可能需要在其他场景中制作一个完整的模拟。
现在,Client 和 ClientStub 都实现了 IClient,正如这个图所示:

图 2.15 – IClient、Client 和 ClientStub 的关系
现在是开发者经常忘记做的步骤:注册服务。请记住,每次您忘记注册服务时,您并不孤单。
更新 IClient 注册
我们将使用我们的 DI 容器来决定何时注入 Client 的实例,何时注入 ClientStub 的实例。在 Program.cs 中,修改 IClient 的初始注册,使其看起来像这样:
builder.Services.AddSingleton<IClient>(_ => {
bool isLoad =
bool.Parse(builder.Configuration["LoadTest:IsActive"]);
if (isLoad) return new ClientStub();
else
{
string apiKey =
builder.Configuration["OpenWeather:Key"];
HttpClient httpClient = new HttpClient();
return new Client(apiKey, httpClient);
}
});
每当请求 IClient 的实例时,DI 容器将根据配置决定注入 ClientStub 或 Client。
现在我们已经完成了模拟实现并准备运行。当您运行项目时,查看 GetReal 方法的输出。如果您已启用负载测试,您将注意到您得到的是模拟版本。
注意事项
我们已经看到了,我敢说这是一种美丽的方式来交换实现。虽然这个例子很小且有限,但在更大的项目中,实现方式将更加出色。考虑以下这些要点:
-
关注点分离,将加载不同版本代码的任务从控制器类移至注册部分。
-
当将
IClient传递给新的控制器时,开发者无需担心或记住执行额外的实现。
与此场景类似,在满足某些条件下需要交换实现时,你可以使用依赖注入(DI)。
此场景位于 GitHub 上 Ch02 源代码的 04-UqsWeather 目录中。
方法注入
在本章中,您已经看到我们一直在通过构造函数注入参数。还有一种不太流行的注入形式,称为 方法注入。这是一个来自 WFA 控制器的例子:
public double ConvertCToF(double c,
[FromServices] ILogger<WeatherForecastController>
logger)
{
double f = c * (9d / 5d) + 32;
logger.LogInformation("conversion requested");
return f;
}
注意到 FromServices 属性。这指示 DI 容器以与构造函数中相同的方式将依赖项注入到方法中。显然,在构造函数中不需要这样做。
当你在类中有多个方法时,你会使用方法注入。其中一个使用特殊的服务。这里的优点是更干净的类构造函数,以及一点性能提升,因为类——例如,控制器——可能会被实例化,但注入服务可能不会被使用。所以,注入但不使用它会有性能浪费。
在这个示例案例中,日志记录器仅在 ConvertCToF 方法中使用,因此它可以从构造函数移动到方法。它只需要在 ConvertCToF 而不是控制器被实例化时注入,以服务于任何其他方法。
最佳实践推荐具有单一职责的类。这导致相关的方法与相关的服务相关联,所以你不会找到方法注入作为一个流行的模式,但如果需要,方法注入是存在的。
属性注入
属性注入是将服务注入到类上的属性。微软容器不支持这种做法,但第三方容器支持。
我见过这种方法在遗留系统中使用,其中 DI 容器正在逐步引入,代码更改最小。然而,我从未在绿色场应用中见过或使用过这种方法。
我相信它没有被添加到微软容器中,因为它不受欢迎,也不被鼓励。
服务定位器
每个容器都自带或集成了一个 服务定位器。服务定位器查找并激活已注册的服务。所以,DI 容器注册一个服务,服务定位器解析已注册的内容。这里是一个使用服务定位器的典型模式:
public class SampleClass
{
private readonly IServiceProvider _serviceProvider;
public SampleClass(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void Method()
{
MyClass myClass =
_serviceProvider.GetService<IMyClass>();
myClass.DoSomething();
}
}
IServiceProvider 是一个支持服务定位的抽象。它可以像任何其他服务一样注入到类中。注意当我们调用 GetService 方法时,它给我们 whatever 是注册了 IMyClass 的。
显然,你可以通过将 IMyClass 注入构造函数来完成同样的事情,这样做甚至更好。你可以在这里看到它是如何被完成的:
public SampleClass(IMyClass myClass)
但有些情况下,你会想要避免注入,而更愿意使用服务定位器。这通常在 DI 没有完全实现的遗留应用程序中使用。
在代码中使用服务定位器会复杂化你的单元测试,所以最好避免使用,一些从业者甚至会将其视为反模式。
摘要
我承认这是一个很长的章节,但我的辩护是它有大量的例子来涵盖许多现实生活中的 DI 场景。此外,DI 自动鼓励良好的软件工程实践,所以我们不得不包括相关的实践。如果你要开发 TDD 风格,你大约会花费 10% 的编码时间来做 DI 相关的任务,我希望这一章节做得正确,并增加了你的知识。
DI 主要与单元测试一起使用,所以没有它,DI 可能感觉不那么有趣。下一章,单元测试入门,将使用我们在这里重构的 WFA 应用程序,希望你能进一步欣赏这种设计模式。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下链接:
-
ASP.NET Core 中的依赖注入(DI):
docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection
第三章:开始单元测试
单元测试是 TDD 的核心,也是实践 TDD 的先决条件。我想简要地介绍必要的最小理论,并更多地熟悉您日常代码中单元测试实践者使用的工具和技术。
在这里,你将学习如何编写覆盖适度编码场景的单元测试。在这本书的第二部分,使用 TDD 构建应用程序中,我们将把本章学到的知识提升到更高水平,并以更真实的方式使用它。
在上一章中,我们构建了天气预报应用程序(WFA)并将其转换为依赖注入(DI)就绪。我们将在这个章节中使用这个应用程序作为学习单元测试的基础。如果你不熟悉 DI 和 DI 容器,我建议首先从第二章,通过示例理解依赖注入开始。
在本章中,我们将做以下几件事:
-
介绍单元测试
-
解释单元测试项目的结构
-
分析单元测试类的结构
-
讨论 xUnit 的基本知识
-
展示 SOLID 原则和单元测试之间的关系
到本章结束时,你将能够编写基本的单元测试。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch03
介绍单元测试
作为 TDD 实践者,你将编写的单元测试代码比生产代码(常规应用程序代码)要多得多。与其他测试类别不同,单元测试将指导你的应用程序的一些架构决策并强制执行依赖注入。
我们不会过多地停留在长定义上。相反,我们将通过大量的示例来展示单元测试。在本节中,我们将讨论 xUnit 单元测试框架和单元测试结构。
什么是单元测试?
单元测试是在交换真实依赖项与测试替身的同时测试一个行为。让我用一个来自 WFA 的WeatherForecastController的例子来支持这个定义。
private readonly ILogger<WeatherForecastController>
_logger;
public double ConvertCToF(double c)
{
double f = c * (9d / 5d) + 32;
_logger.LogInformation("conversion requested");
return f;
}
这个方法将摄氏度转换为华氏度并记录每次调用。在这里,日志不是关注点,因为这个方法关注的是转换。
这个方法的行为是将输入的摄氏度转换为华氏度,这里的日志依赖项是通过_logger对象访问的。在运行时,我们正在注入一个Logger<>,它将写入物理介质,但在测试时我们可能希望消除写入的副作用。
根据前面的定义,我们需要在运行时将_logger使用的真实依赖项与其测试替身交换,并测试转换行为。我们将在本章后面展示如何做这件事。
从同一个类中再举一个例子:
private readonly IClient _client;
public async Task<IEnumerable<WeatherForecast>> GetReal()
{
…
OneCallResponse res = await _client.OneCallAsync(…
…
}
这个方法的行为是获取实际的天气预报并将其传递给调用者。这里的 _client 对象代表 OpenWeather 依赖项。这个方法的行为 不是 与 OpenWeather API 的 RESTful 协议或 HTTP 协议的细节进行交互。这由 _client 处理。我们需要在运行时交换 _client 使用的真实依赖项 Client,并用一个适合测试的依赖项替换它(我们称之为 测试替身)。我将在 第四章,使用测试替身的真实单元测试 中展示如何做到这一点。
在这个阶段,这个概念可能仍然很晦涩,我知道;请耐心等待,我们将开始逐步展开。在下一节中,我们将讨论单元测试框架。我们需要这些来对前面的示例和 WFA 进行单元测试。
单元测试框架
.NET 6 有三个主要的单元测试框架。最受欢迎的是 xUnit,我们将在这本书中使用它。其他两个是 NUnit 和 MSTest:
-
NUnit 是一个开源库。它最初是从 Java 的 JUnit 框架移植过来的,后来被完全重写。你仍然会在遗留项目中遇到它,但今天的大多数项目都是从 xUnit 开始的。
-
MSTest 是微软的单元测试框架,因其曾随 Visual Studio 一起提供而受到欢迎,当时无需额外努力即可安装它,尤其是在 NuGet 还不存在的时候。它从版本 2 开始开源,并且在功能上一直落后于 NUnit 和 xUnit。
-
xUnit 是一个由 NUnit 开发者发起的开源项目。它功能丰富,并且处于持续开发中。
注意
XUnit 这个术语是一个涵盖不同语言单元测试框架的总称,例如 JUnit(Java)、NUnit(.NET)、xUnit(.NET)和 CUnit(C 语言)。这个术语不应与库名称 xUnit 混淆,xUnit 是一个 .NET 单元测试库,其创始人选择了一个已被占用且容易混淆的名字。
学习一个框架然后切换到另一个框架应该不会花费太多时间,因为它们很相似,你只需要了解特定框架使用的术语。接下来,我们将向解决方案中添加一个 xUnit 项目来对 WFA 进行单元测试。
理解测试项目
xUnit 模板是 VS 的一部分。我们将展示如何使用 .NET CLI 方法添加 xUnit 项目。在这个阶段,如果你还没有打开从 第二章,通过示例理解依赖注入 转移到这一章的配套源代码,我鼓励你这样做。
通过 CLI 添加 xUnit
目前,我们有一个包含一个 ASP.NET Core 项目的解决方案。现在,我们想要将单元测试库添加到我们的解决方案中。为此,在同名目录下创建一个新的 xUnit 项目,命名为 Uqs.Weather.Tests.Unit,并使用 .NET 6.0:
dotnet new xunit -o Uqs.Weather.Tests.Unit -f net6.0
将新创建的项目添加到解决方案文件中:
dotnet sln add Uqs.Weather.Tests.Unit
现在,我们的解决方案中有两个项目。由于单元测试项目将测试 ASP.NET Core 项目,单元测试项目应引用 ASP.NET Core 项目。
从Uqs.Weather.Tests.Unit在Uqs.Weather上添加项目引用:
dotnet add Uqs.Weather.Tests.Unit reference Uqs.Weather
我们现在已通过 CLI 完全构建了解决方案。你可以在这里看到完整的交互:

图 3.1 – 通过 CLI 在解决方案中创建新的 xUnit 项目
现在我们有一个项目来存放我们的单元测试。
测试项目命名约定
你已经注意到我们将.Tests.Unit附加到原始项目名称上,因此单元测试项目变为Uqs.Weather.Tests.Unit。这是命名测试项目的常见约定。
此约定也适用于其他测试项目,例如集成测试和 S 集成测试,将在第四章“更多测试类别”部分中讨论。你可能还会有以下情况:
-
Uqs.Weather.Tests.Integration -
Uqs.Weather.Tests.Sintegration
此约定的智慧在于,你可以查看你的项目列表,并快速找到与一个生产代码项目相关的测试项目,它们按顺序排列,如下所示:

图 3.2 – 有序单元测试项目
此约定还有助于在持续集成中针对所有测试项目,这将在第十一章“使用 GitHub Actions 实现持续集成”中介绍,如果你想要运行所有测试类别。以下是一个示例:Uqs.Weather.Tests.*。
运行示例单元测试
xUnit 模板附带一个名为UnitTest1.cs的示例单元测试类,其中包含以下内容的示例单元测试方法:
using Xunit;
namespace Uqs.Weather.Tests.Unit;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
这有一个名为Test1的单个单元测试,目前它是空的,不做任何事情。为了检查 xUnit 框架和与 VS 的集成是否正常工作,你可以尝试执行此单个测试。
从 VS 菜单中选择测试|运行所有测试或使用类似的方法,通过Ctrl + R, A键盘快捷键执行。这将执行项目中的所有测试(目前只有一个测试),你将拥有以下工具,称为测试资源管理器。

图 3.3 – 测试资源管理器
这里显示的层次结构是项目名称|测试类命名空间|测试类名称|测试方法名称。
如果你喜欢 CLI,你可以使用命令提示符进入解决方案目录并执行以下操作:
dotnet test
这可能就是你得到的结果:

图 3.4 – CLI dotnet 测试结果
我看到测试资源管理器在日常 TDD 风格开发中比 CLI(命令行界面)使用得更多。CLI 对于运行整个解决方案或进行持续集成和自动化运行很有用。
测试资源管理器
测试资源管理器是 VS 的一部分。此外,xUnit 添加了一些库,允许测试资源管理器和 VS 与 xUnit 测试交互。还有一些第三方提供程序具有更高级的测试运行器。其中之一是JetBrains ReSharper Unit Test Explorer。我们已经准备好开始编写单元测试代码。
单元测试类结构
当我们进行单元测试时,我们倾向于编写一个针对并行生成类的单元测试类——一个测试类对应一个生成类。
将此概念应用于我们的 WFA 项目,我们的生成类是WeatherForecastController,单元测试类将被命名为WeatherForecastControllerTests。因此,将UnitTest1示例类重命名为WeatherForecastControllerTests。
小贴士
你可以在源代码中类的名称内的任何位置设置你的文本光标(在前一个例子中,它是UnitTest1)并按Ctrl + R,R(按住Ctrl然后快速连续按两次R)。输入新名称WeatherForecastControllerTests并按Enter。如果勾选了重命名符号的文件复选框,这也会重命名文件。
接下来,我们将了解如何组织我们的单元测试类及其方法。
类命名约定
我发现最常用的约定是将单元测试类的名称与生成代码类的名称相同,并附加Tests后缀。例如,MyProductionCode测试类的对应项将是MyProductionCodeTests。
在实践 TDD(测试驱动开发)时,你需要在短时间内多次在测试类及其对应的生成代码类之间切换。使用这种模式命名可以使你轻松找到测试及其相关对应项,反之亦然。这也有助于阐明两个类之间的关系。
测试方法
每个测试类包含测试生产代码类中功能片段的方法,这些片段被称为单元。以测试ConvertCToF方法为例。
测试示例 1
我们的部分要求是测试具有单个小数点精度的转换。因此,让我们考虑一个测试案例,取一个零度(0.0 C)并测试方法是否返回 32.0 F。为此,我们可以在单元测试类中删除Test1方法,并用以下内容替换它:
[Fact]
public void ConvertCToF_0Celsius_32Fahrenheit()
{
const double expected = 32d;
var controller = new WeatherForecastController(
null!, null!, null!, null!);
double actual = controller.ConvertCToF(0);
Assert.Equal(expected, actual);
}
此代码初始化生成代码类,调用测试中的方法,然后评估测试结果与我们的预期。
Fact是一个属性,它使方法成为单元测试。Assert是一个静态类,它包含用于比较预期结果与实际结果的有用方法。Fact和Assert都是 xUnit 框架的一部分。
使用Ctrl + R,A通过测试资源管理器运行此测试,测试将显示以下屏幕:

图 3.5 – 测试资源管理器中的失败输出
如果我们查看控制器内部,我们会发现 VS 有一个红色标志来映射导致此失败的路径:

图 3.6 – VS 显示失败的测试路径
从错误信息中可以明显看出导致ArgumentNullException的原因:
_logger.LogInformation("conversion requested");
这是预期的,因为我们已经将logger参数从单元测试中作为null传递。我们希望_logger.LogInformation不执行任何操作,为此,我们将使用官方文档中指示的NullLogger<>,它不执行任何操作。我们的单元测试代码需要更改为以下内容,以便我们可以用模拟器替换真实日志记录器:
var logger =
NullLogger<WeatherForecastController>.Instance;
var controller = new WeatherForecastController(
logger, null!, null!, null!);
如果您再次运行测试,所有红色都将变为绿色,测试将通过。
测试示例 2
要测试方法的其他输入和输出,我们可以在类中添加更多单元测试,并遵循相同的测试方法名称模式。我们可以有以下几种:
public void ConvertCToF_1Celsius_33p8Fahrenheit() {…}
…
public void ConvertCToF_Minus1Celsius_30p2Fahrenheit() {…}
但是,有一个简洁的解决方案可以避免为每个值组合编写类似的单元测试,如下所示:
[Theory]
[InlineData(-100 , -148)]
[InlineData(-10.1, 13.8)]
[InlineData(10 , 50)]
public void ConvertCToF_Cel_CorrectFah(double c, double f)
{
var logger =
NullLogger<WeatherForecastController>.Instance;
var controller = new WeatherForecastController(
logger, null!, null!, null!);
double actual = controller.ConvertCToF(c);
Assert.Equal(f, actual, 1);
}
注意,我们使用的是Theory而不是Fact。每个InlineData都将作为一个单独的单元测试。您甚至可以消除示例 1并将其作为InlineData属性。无需说明Theory和InlineData是来自 xUnit 的属性。
您可以继续运行测试。
其他示例在第一章《编写您的第一个 TDD 实现》中有所介绍,与本章中前面的示例类似,因此您可以查看以获得更多清晰度。
示例 1和示例 2针对一个简单的方法ConvertCToF,它有一个单一的依赖项_logger。在学习了第四章《使用测试替身进行真实单元测试》中的测试替身后,我们将涵盖更复杂的测试场景。实际上,您的生产代码将比简单的转换方法更复杂,并将包含多个依赖项,但万事开头难。
命名约定
单元测试方法名称遵循一个流行的约定:待测试方法 _ 条件 _ 预期。我们之前已经看到了这个约定的使用。以下是一些假设的示例:
-
SaveData_CannotConnectToDB_InvalidOperationException -
OrderShoppingBasket_EmptyBasket_NoAction
本书还包含许多其他示例,这些示例应进一步阐明此约定。
安排-行动-断言模式
之前的测试方法和一般所有的单元测试方法都遵循类似的模式:
-
创建一个状态,声明一些变量,并进行一些准备工作。
-
调用待测试的方法。
-
将实际结果与预期进行断言。
实践者决定给这三个阶段以下名称:
安排、行动和断言(AAA)。
他们通过注释标记代码以显示阶段并强调分隔。根据这一点,我们可以将之前的测试方法写成如下:
[Fact]
public void ConvertCToF_0Celsius_32Fahrenheit()
{
// Arrange
const double expected = 32d;
var controller = new WeatherForecastController(…);
// Act
double actual = controller.ConvertCToF(0);
// Assert
Assert.Equal(expected, actual);
}
注意代码中添加的注释。
重要提示
有些团队不喜欢通过注释来分隔,相反,他们选择不同的方式来标记 AAA,例如,在每一部分之间留一个空行。
AAA 实践不仅仅是一种约定。它使得方法在即时阅读时更容易理解。它还强调单元测试方法中应该只有一个 操作。因此,根据最佳实践,单元测试不应该有超过一个 AAA 结构。
使用 VS 代码片段
每个单元测试都将具有相同的结构。VS 允许你通过本章源代码中的 CodeSnippets 目录来减少编写相同结构的代码。它被称为 aaa.snippet。你可以通过常规文本编辑器(而不是文字处理器)打开它并查看/编辑其内容。
要在 Windows 上使用此片段,请将 aaa.snippet 复制到该目录(选择正确的 VS 版本):
%USERPROFILE%\Documents\Visual Studio 2022\Code Snippets\Visual
C#\My Code Snippets
一旦复制完成,在你的单元测试类中输入 aaa,然后按 Tab 键,你将得到以下生成的代码:
[Fact]
public void Method_Condition_Expectation()
{
// Arrange
// Act
// Assert
}
而不是在单元测试中过多地谈论只有一个 AAA,我们将在这本书中通过示例来展示资深开发者编写单元测试时所使用的风格。
现在我们已经对类的结构和单元测试方法的结构有了概述,我们将探讨单元测试类的对应物:系统测试对象。
系统测试对象
单元测试旨在测试生产代码的一个单一功能。每个单元测试类都有一个正在被测试的生产代码对应物。我们将被测试的生产代码称为 系统测试对象(SUT)。你可以在这里看到 SUT 的一个示意图:

图 3.7 – 对 SUT 执行的单元测试
SUT 这个术语是最常用的,但你可能会发现其他人将其称为 测试类(CUT)、测试代码(CUT – 是的,它是同一个缩写),或者 测试方法(MUT)。
SUT 这个术语在开发者的对话中使用,也常在代码中使用,以明确指出正在测试的内容,如下所示:
var sut = new ProductionCode(…);
理解你的单元测试类的 SUT 非常重要。随着你的项目逐渐增长,你将逐渐注意到形成了一种模式,如下所示:

图 3.8 – 单元测试项目与生产代码项目对比
每个单元测试类都与一个 SUT 对应物配对。
现在我们已经在这里和 第一章 中看到了 xUnit 的几个特性,现在是时候更深入地了解 xUnit 了。
xUnit 的基础知识
xUnit 为你的测试提供托管环境。xUnit 的一个重要特性是它对 AAA 约定友好。它还与 VS IDE 及其测试资源管理器集成。
本书自然地出现了大量使用 xUnit 的示例。然而,值得专门用几个部分来讨论这个框架的主要特性。
Fact 和理论属性
在你的测试项目中,任何被Fact或Theory装饰的方法都将成为测试方法。Fact用于非参数化单元测试,而Theory用于参数化测试。使用Theory,你可以添加其他属性,例如InlineData,用于参数化。
注意
VS 会在方法名上方给出一个视觉指示,表明你可以运行带有这些属性的装饰方法,但有时直到你运行所有测试才会出现。
运行测试
每个单元测试将独立运行并实例化该类。单元测试不共享彼此的状态。因此,单元测试类与普通类的运行方式不同。让我用一个示例代码来详细说明,如下所示:
public class SampleTests
{
private int _instanceField = 0;
private static int _staticField = 0;
[Fact]
public void UnitTest1()
{
_instanceField++;
_staticField++;
Assert.Equal(1, _instanceField);
Assert.Equal(1, _staticField);
}
[Fact]
public void UnitTest2()
{
_instanceField++;
_staticField++;
Assert.Equal(1, _instanceField);
Assert.Equal(2, _staticField);
}
}
之前的单元测试是可以通过的。注意,虽然我在两个测试方法中都增加了_instanceField的值,但_instanceField的值在这两个方法之间并不共享,每次 xUnit 实例化一个方法时,我的整个类都会再次实例化。这就是为什么每次方法执行之前都会将值重置为0的原因。xUnit 的这个特性促进了单元测试原则中的一种称为无依赖性的原则,这将在第六章,TDD 的 FIRSTHAND 指南中讨论。
另一方面,静态字段在两个方法之间共享,其值已更改。
重要提示
虽然我已经使用实例和静态字段来展示单元测试类的独特行为,但我想要强调,在单元测试中使用静态的read-write字段是一种反模式,因为这打破了无依赖性原则。一般来说,你应该在单元测试类中没有公共的write字段,字段最好用readonly关键字标记。
相反,如果相同的方法是常规代码类(不是单元测试类)的一部分,并且都被调用,我们期望找到_instanceField的值增加到2,但这里并非如此。
Assert 类
Assert是一个静态类,它是 xUnit 的一部分。这是官方文档对Assert类的定义:
包含各种静态方法,用于验证条件是否满足。
让我们快速概述一下Assert的一些方法:
-
Equal(expected, actual): 这是一系列重载,将比较期望值与实际值。你已经在第一章,编写你的第一个 TDD 实现和本章中看到了一些Equal的例子。 -
True(actual): 与使用Equal比较两个对象相比,在相关的地方可以使用这个方法来提高可读性。让我们用一个例子来澄清这一点:Assert.Equal(true, isPositive); // or Assert.True(isPositive); -
False(actual): 之前方法的相反。 -
Contains(expected, collection): 一组重载,用于检查集合中是否存在单个元素。 -
DoesNotContain(expected, collection): 之前方法的相反。 -
Empty(collection): 这验证了一个集合是否为空。 -
Assert.IsType<Type>(actual): 这验证了一个对象是否为特定类型。
由于有更多方法,我鼓励你访问官方 xUnit 网站查看,或者像大多数开发者一样:在一个单元测试类中编写Assert,并在其后输入一个点来触发 IntelliSense 并查看显示的方法。
Assert方法将与测试运行器(如测试资源管理器)通信,以报告断言的结果。
记录类
Record类是一个静态类,用于记录异常,以便你可以测试你的方法是否抛出了正确的异常。这是其静态方法之一,称为Exception()的示例:
public static System.Exception Exception(Action testCode)
之前的代码返回Action抛出的异常。让我们以这个例子为例:
[Fact]
public void Load_InvalidJson_FormatException()
{
// Arrange
string input = "{not a valid JSON";
// Act
var exception = Record.Exception(() =>
JsonParser.Load(input));
// Assert
Assert.IsType<FormatException>(exception);
}
在这里,我们正在检查当Load方法遇到无效的 JSON 输入时,是否会抛出FormatException。
这是对 xUnit 功能的一个总结,这应该能帮助你开始编写基本的单元测试。
将 SOLID 原则应用于单元测试
SOLID 原则在网络上和书籍中被广泛覆盖和宣传。很可能这不是你第一次听说或读到它们。它们也是流行的面试问题。SOLID 原则代表以下内容:
-
单一职责原则:
-
开放封闭原则:
-
里氏替换原则:
-
接口隔离原则:
-
依赖倒置:
在本节中,我们主要关注 SOLID 原则与单元测试之间的关系。虽然并非所有原则都与单元测试有很强的联系,但我们将涵盖所有原则以完成。
单一职责原则
单一职责原则(SRP)是关于每个类只具有一个职责。这将导致它只有一个变更的理由。这种方法的优点如下:
- 更容易阅读和理解的类:
类将拥有更少的方法,这应该会导致更少的代码。其接口也将拥有更少的方法。
- 更改功能时的涟漪效应更小:
有更少的类需要更改,这将导致更改更容易。
- 更小的变更概率,这意味着更少的潜在错误:
代码越多,潜在的错误就越多,更改代码也会导致潜在的错误。一开始就有更少的代码意味着更少的代码更改。
示例
SRP(单一职责原则)不是一门精确的科学,挑战在于能够决定什么是 职责。每个开发者都有自己的看法。下一个例子将说明这个想法。
假设你创建了自己的文件格式称为 ABCML 来解决特定问题,因为现有的文件格式(如 JSON、XML 等)不能满足你的特定需求。一组具有单一职责的类可能如下所示:
-
一个用于验证文件内容是否具有正确结构的类
-
一个用于将 ABCML 导出为通用格式的类
-
一个继承通用 ABCML 导出以支持导出到 JSON 的类,以及一个支持导出到 XML 的类
-
一个表示 ABCML 中的节点的类
-
更多类
你可以看到我是如何将职责分割成单个类的,尽管没有单一的设计来确保单一职责。
SRP 和单元测试
自然地,在进行单元测试时,你会考虑一个类的单一职责,并且你将你的单元测试类命名为与 tests 后缀相同的名称。所以,如果你正在考虑测试 ABCML 文件格式的验证,你可能有 ABCMLValidationTests。
在你的单元测试类中,每个单元测试都针对你的 SUT(系统单元)中的单一行为。这些行为结合起来导致一个单一职责。

图 3.9 – 针对一个单一职责的多个单一行为测试
上一图显示了多个测试,每个测试都专注于单一行为,并且它们针对一个职责:验证。在右侧,有一个方法,但这只是为了说明,因为你可能有多个公共方法,你仍然可以有一个单一职责。
在 第六章 的 TDD 的 FIRSTHAND 指南 中,我们将介绍一个称为 单一行为指南 的指南。这个指南与 TDD 和单元测试一起工作,以鼓励 SRP。
开放封闭原则
开放封闭原则(OCP)是关于准备你的类以便它可以被继承(使其开放),这样任何新功能的添加都可以直接继承这个类而不需要修改它(使其封闭)。
这个原则的本质是在每次添加新功能时最小化不必要的更改。
示例
让我们用一个例子来说明这一点更清楚。假设我们创建了一个用于进行算术计算的库。让我们首先 不遵守 OCP,如下所示:
public interface IArithmeticOperation {}
public class Addition : IArithmeticOperation
{
public double Add(double left, double right) =>
left + right;
}
public class Subtraction : IArithmeticOperation { … }
public class Calculation
{
public double Calculate(IArithmeticOperation op,
double left, double right) =>
op switch
{
Addition addition => addition.Add(left, right),
Subtraction sub => sub.Subtract(left, right),
//Multiplication mul => mul.Multiply(left,right),
_ => throw new NotImplementedException()
};
}
上一段代码中的 Calculate 方法每次添加新的 ArithmeticOperation 时都需要更改。如果我们想在稍后阶段添加乘法操作作为功能,根据注释行,Calculate 方法需要更改以适应新功能。
我们可以通过消除每次添加新操作时更改Calculate方法的需求来使实现更符合 OCP。让我们看看如何做到这一点:
public interface IArithmeticOperation
{
public double Operate(double left, double right);
}
public class Addition : IArithmeticOperation
{
public double Operate(double left, double right) =>
left + right;
}
public class Subtraction : IArithmeticOperation { … }
// public class Multiplication : IArithmeticOperation { … }
public class Calculation
{
public double Calculate(IArithmeticOperation op,
double left, double right) =>
op.Operate(left, right);
}
之前的示例利用多态来阻止每次添加新操作时更改Calculation方法。你可以从注释行中看到如何添加新的乘法操作。这是一个更符合 OCP 的方法。
注意
当我把所有类和接口在这里和 GitHub 代码中一起列出时,我这样做是为了说明,因为它们通常被分到自己的文件中。所以,使用 OCP,你也可以减少更改文件的机会,并在源代码控制级别更容易理解更改的内容。
OCP 和单元测试
单元测试通过确保更改不会意外地破坏现有功能来保护任何类的更改。OCP 和单元测试是相辅相成的。因此,虽然 OCP 减少了可避免更改的机会,但单元测试在更改时通过验证业务规则增加了额外的保护层。
Liskov 替换原则
Liskov 替换原则(LSP)指出,子类的一个实例必须替换父类的一个实例,而不会影响我们从基类实例本身得到的结果。子类应该是其父类的真实表示。
示例
我们将使用一种学术类型的例子来使概念更容易理解。让我们看看以下例子:
public abstract class Bird
{
public abstract void Fly();
public abstract void Walk();
}
public class Robin : Bird
{
public override void Fly() => Console.WriteLine("fly");
public override void Walk() =>
Console.WriteLine("walk");
}
public class Ostrich : Bird
{
public override void Fly() =>
throw new InvalidOperationException();
public override void Walk() =>
Console.WriteLine("walk");
}
在之前的代码中,根据 LSP,Ostrich不应该继承Bird。让我们修正代码以符合 LSP:
public abstract class Bird
{
public abstract void Walk();
}
public abstract class FlyingBird : Bird
{
public abstract void Fly();
}
public class Robin : FlyingBird
{
public override void Fly() => Console.WriteLine("fly");
public override void Walk() =>
Console.WriteLine("walk");
}
public class Ostrich : Bird
{
public override void Walk() =>
Console.WriteLine("walk");
}
我们通过引入一个名为FlyingBird的新中间类来改变继承层次结构,以符合 LSP。
LSP 和单元测试
单元测试对 LSP 没有直接影响,但在这里提及 LSP 是为了完整性。
接口隔离原则
接口隔离原则(ISP)指出,子类不应该被迫依赖于它们不使用的接口。接口应该更小,以便实现它们的任何人都可以混合和匹配。
示例
我总是认为.NET 中集合的实现是解释这个原则的最佳例子。让我们看看List<T>是如何声明的:
public class List<T> : ICollection<T>, IEnumerable<T>,
IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, IList
它实现了六个接口。每个接口包含有限数量的方法。List<T>提供了大量的方法,但它这样做是通过选择多个接口,每个接口添加几个方法。
List<T>公开的一个方法是GetEnumerator()。此方法来自IEnumerable<T>接口;实际上,它是IEnumerable<T>上的唯一方法。
通过拥有小的接口(如本例中的接口,包含少量相关方法),List<T>能够选择它需要实现的内容,不多也不少。
ISP 和单元测试
单元测试对 ISP 没有直接影响,但在这里提及 ISP 是为了完整性。
依赖倒置原则
依赖倒置原则(DIP)指出,高级模块不应该依赖于低级模块。两者都应依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象。换句话说,DIP 是一个通过使用抽象和 DI 来促进类之间松耦合的原则。
示例
第二章,通过示例理解依赖注入,专注于这个主题,并富含将代码修改为启用 DI 的示例。
DIP 和单元测试
DIP 和单元测试之间存在紧密的关系。没有 DI,真正的单元测试无法运行。事实上,花费在使一切可注入并为没有接口的类提供适当接口设计上的努力,作为副产品促进了 DIP。
您可以看到,SRP 和 DIP 都是由单元测试所促进的。因此,当您提高生产质量的同时,设计质量也在提高。单元测试需要付出努力,这是毫无疑问的,但其中一部分努力已经投入到您的代码设计质量和可读性中。
摘要
在本章中,我们简要介绍了基本的单元测试相关主题,并探讨了几个示例。
如果我要将单元测试经验从 1 到 5 进行分类,其中 1 级是初学者,5 级是专家,那么本章应该能帮助您达到 2 级。别担心!在阅读完本书的其余部分,其中将包含更多现实世界的示例后,您将达到 4 级,所以我很高兴您已经走这么远了。继续前进!
这本书会带我达到 5 级水平吗? 我听到您在问。嗯,单元测试不是短跑,而是一场马拉松;要达到那个水平需要多年的实践,只有真正动手做单元测试才能达到那个水平。
我们还涵盖了 SOLID 原则与单元测试之间的关系,以展示整体图景以及所有内容是如何完美结合在一起的。
在本章中,我故意避免了需要深入了解测试替身的示例,以便以温和的方式向您介绍单元测试。然而,在现实中,大多数单元测试都将需要测试替身。让我们向前迈进到一个更现实的范围,并在下一章深入探讨这个概念。
进一步阅读
要了解更多关于本章讨论的主题,您可以参考以下链接:
-
代码片段创建流程:
docs.microsoft.com/en-us/visualstudio/ide/walkthrough-creating-a-code-snippet -
xUnit:
xunit.net
第四章:使用测试替身进行真实单元测试
单元测试与其他测试类别不同,因为它使用测试替身;实际上,你很少会看到没有测试替身的单元测试。
网上关于这究竟意味着什么的混淆很多。在本章中,我的目标是澄清这个术语,以便你可以在正确的上下文中使用它,并为你提供尽可能多的关于这个主题的解释示例,让你在选择手头的测试替身时感到自信。
在本章中,我们将:
-
解释测试替身的概念和用法
-
讨论更多测试类别
到本章结束时,你将了解单元测试的特殊之处,并将能够使用测试替身开始编写真实的单元测试。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch04
理解和使用测试替身
你很少会编写一个不使用测试替身的单元测试。将术语替身与好莱坞特技演员在某种情况下取代真实演员的行为视为相同。测试替身是一个总称,用于替换依赖项以测试 SUT 的测试等效物(替身)。它们旨在满足以下一个或多个需求:
需求 1:使测试代码能够编译。
需求 2:根据单元测试要求消除副作用。
需求 3:嵌入与真实行为相关联的(预定)行为。
需求 4:记录并验证在单元测试中对依赖项施加的活动(我们稍后将此需求命名为间谍活动)。
当我们讨论单个测试替身类型时,我们将参考这四个条件,因此你可能想要将这一节添加到书签中。
你想在单元测试时调用支付网关并执行交易吗?你想在单元测试时调用需要付费的第三方 API 吗?你甚至在测试时想通过 HTTP 吗?提示:你不想,也不应该。
让我们了解可以满足前面提到的四个条件的不同类型的测试替身。
测试替身的类型
有五种主要的测试替身类型——每一种都是为了满足前面提到的四个需求之一。在单元测试时,你可以使用零个或多个测试替身类型来满足你的测试。
接下来,我们将讨论模拟器、存根、模拟和伪造。这四种类型的测试替身通常与 TDD 一起使用。第五种类型是隔离,它不与 TDD 一起使用,这里仅为了完整性而提及。
Dummies
ConvertCToF方法:
// Constructor
public WeatherForecastController(
ILogger<WeatherForecastController> logger,
IClient client, INowWrapper nowWrapper,
IRandomWrapper randomWrapper)
…
public double ConvertCToF(double c)
{
double f = c * (9d / 5d) + 32;
_logger.LogInformation("conversion requested");
return f;
}
要测试 ConvertCToF,我们必须实例化一个 WeatherForecastController 类。构造函数期望传递多个对象以实例化控制器类:logger、client、nowWrapper 和 randomWrapper。但是 ConvertCToF 只使用了 _logger。此外,我们不想测试 _logger 的副作用,因为我们正在测试另一种行为。因此,我们决定使用 NullLogger<>。我们可以像这样将所有虚拟替身传递给我们的控制器:
var logger =
NullLogger<WeatherForecastController>.Instance;
var sut = new WeatherForecastController(logger, null, null,
null);
当使用 logger 时,它什么都不做,其他 null 值只是传递过去以使代码编译。在这种情况下,logger 和 null 值充当了虚拟测试替身。
当可以使用虚拟替身时创建 智能 测试替身可能会使单元测试变得复杂并模糊其意图,因此当可能时使用虚拟替身。
虚拟替身满足我们之前提到的第一个和第二个测试替身要求。它们允许代码编译,并在调用时创建不执行任何操作的对象。
存根
WFA 控制器的 GetReal() 方法:
OneCallResponse res = await _client.OneCallAsync
(GREENWICH_LAT, GREENWICH_LON, new[] {
Excludes.Current, Excludes.Minutely,
Excludes.Hourly, Excludes.Alerts }, Units.Metric);
WeatherForecast[] wfs = new WeatherForecast[FORECAST_DAYS];
for (int i = 0; i < wfs.Length; i++)
{
var wf = wfs[i] = new WeatherForecast();
wf.Date = res.Daily[i + 1].Dt;
double forecastedTemp = res.Daily[i + 1].Temp.Day;
wf.TemperatureC = (int)Math.Round(forecastedTemp);
wf.Summary = MapFeelToTemp(wf.TemperatureC);
}
return wfs;
我们正在使用 _client,一个依赖服务,并调用 OneCallAsync 从 OpenWeather 获取天气数据。我们将结果保存在 res 对象中。OneCallResponse 不是我们想要返回给 GetReal() API 消费者的内容。相反,我们希望向消费者提供一个简单的输出集合,其类型为 WeatherForecast[]。因此,我们有一个映射过程,它将来自 _client.OneCallAsync 的数据映射到 WeatherForecast[]。
在前面的代码中,将映射过程与 OpenWeather 连接起来的点是 OneCallAsync 调用:

图 4.1 – 我们需要测试的单元
我们希望用我们自己的存根实现替换 OneCallAsync 的实现,以避免调用真实的 RESTful API,因为我们正在测试的是映射业务逻辑。幸运的是,我们可以使用 多态 来替换实现。这可以通过创建一个名为 ClientStub 的具体类并编写我们自己的 OneCallAsync 方法来实现 IClient。我们的最终设计如下:

图 4.2 – 客户端和 ClientStub 实现 IClient
让我们构建我们的存根类:
public class ClientStub : IClient
{
private readonly DateTime _now;
private readonly IEnumerable<double> _sevenDaysTemps;
public ClientStub(DateTime now,
IEnumerable<double> sevenDaysTemps)
{
_now = now;
_sevenDaysTemps = sevenDaysTemps;
}
public Task<OneCallResponse> OneCallAsync(
decimal latitude, decimal longitude,
IEnumerable<Excludes> excludes, Units unit)
{
const int DAYS = 7;
OneCallResponse res = new OneCallResponse();
res.Daily = new Daily[DAYS];
for (int i = 0; i < DAYS; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = _now.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day =
_sevenDaysTemps.ElementAt(i);
}
return Task.FromResult(res);
}
}
注意以下代码中的内容:
-
ClientStub实现IClient,并且它应该为OneCallAsync提供一个实现以满足契约。 -
构造函数允许类的用户提供
DateTime和七天的温度。 -
OneCallAsync方法有一个虚构的、存根的实现,它生成一个OneCallResponse返回值。
现在我们已经编写了类,我们可以将其付诸实践。我们有几个测试标准想要测试。以下是第一个测试和第一个标准:
public async Task
GetReal_NotInterestedInTodayWeather_WFStartsFromNextDay()
{
// Arrange
const double nextDayTemp = 3.3;
const double day5Temp = 7.7;
var today = new DateTime(2022, 1, 1);
var realWeatherTemps = new double[]
{2, nextDayTemp, 4, 5.5, 6, day5Temp, 8};
var clientStub = new ClientStub(today,
realWeatherTemps);
var controller = new WeatherForecastController(
null!, clientStub, null!, null!);
// Act
IEnumerable<WeatherForecast> wfs = await
controller.GetReal();
// Assert
Assert.Equal(3, wfs.First().TemperatureC);
}
注意,我们正在决定今天是哪一天。这是我们冻结日期以便测试可以在任何时间执行的方式。我们也在决定从我们虚构的那一天开始未来 7 天的天气。我们需要这样做才能实例化 ClientStub,以便它可以根据这些值做出响应。
从测试名称中,它应该被结构化为 Method_Condition_Expectation,我们可以推断出在这个测试中我们试图做什么。现实中我们得到的天气包含从今天开始的 7 天,但我们在 WeatherForecast[] 中返回的是从下一天开始的未来 5 天的预报。因此,我们忽略今天的天气,并且没有在任何地方使用它。
存根保护我们免于访问真实的天气服务,并提供了我们在 Arrange 部分中包含的预定义值。如果我们调用真实的服务,我们会得到不可预测的天气,从测试的角度来看,对于不可预测的日子(取决于我们何时运行测试),这不会使我们能够编写我们的 Assert 标准。
这个测试不足以覆盖所有应该测试的标准。你可以在本章的 WeatherForecastControllerTests 类的源代码中找到更多针对 GetReal 方法的测试。这些测试是:
GetReal_5DaysForecastStartingNextDay_
WF5ThDayIsRealWeather6ThDay
GetReal_ForecastingFor5DaysOnly_WFHas5Days
GetReal_WFDoesntConsiderDecimal_
RealWeatherTempRoundedProperly
GetReal_TodayWeatherAnd6DaysForecastReceived_
RealDateMatchesNextDay
GetReal_TodayWeatherAnd6DaysForecastReceived_
RealDateMatchesLastDay
我鼓励你查看配套代码,以便熟悉其他示例。
间谍
间谍 是添加到存根类中的额外功能,用于揭示存根内部发生了什么。例如,考虑这个业务需求,我们需要确保我们只向 OpenWeather 传递公制温度(摄氏度)请求。
我们需要修改我们的存根以揭示传递给 OneCallAsync 的内容。存根中的新代码将看起来像这样:
public Units? LastUnitSpy { get; set; }
public Task<OneCallResponse> OneCallAsync(decimal latitude,
decimal longitude, IEnumerable<Excludes> excludes,
Units unit)
{
LastUnitSpy = unit;
const int DAYS = 7;
// the rest of the code did not change
我们添加了一个名为 LastUnitSpy 的属性来存储最后请求的单位,并以 Spy 后缀作为惯例。我们的单元测试将看起来像这样:
public async Task
GetReal_RequestsToOpenWeather_MetricUnitIsUsed()
{
// Arrange
var realWeatherTemps = new double[] { 1,2,3,4,5,6,7 };
var clientStub = new ClientStub(
default(DateTime), realWeatherTemps);
var controller = new WeatherForecastController(null!,
clientStub, null!, null!);
// Act
var _ = await controller.GetReal();
// Assert
Assert.NotNull(clientStub.LastUnitSpy);
Assert.Equal(Units.Metric,
clientStub.LastUnitSpy!.Value);
}
注意,在这个测试中,我们没有用有意义的值填充预报温度,并使用了默认的 DateTime。这强调了对于未来的测试读者(阅读代码的其他开发者)来说,我们在这个测试中不关心这些参数的变化。我们只是想要一些虚拟对象来实例化 clientStub 对象。
最后的断言验证了接收到了 Units.Metric,这满足了我们的业务需求。
你可以根据你的测试按需添加间谍,你可以以你喜欢的任何方式组织它们,并且希望到现在为止,将它们称为 间谍 的想法已经变得有意义。
使用存根的优缺点
使用存根简单且代码易于阅读。不需要学习任何特殊的存根框架也是一个优点。
存根的问题在于,你的场景越复杂,你需要更多的存根类(如 ClientStub2 和 ClientStub3),或者你的存根实现需要更加巧妙。你的存根应该包含最小的 巧妙性 和业务逻辑。在现实场景中,如果你和你的团队不仔细维护,你的存根将会变得庞大且难以维护。
上一个场景的回顾
我们遵循以下步骤对 GetReal() 方法进行单元测试:
-
我们意识到
_client是我们 SUT(系统单元)使用的依赖项。 -
我们希望将
GetReal方法与调用真实的 OpenWeather 服务隔离,因此我们需要为_client提供一种替代行为。 -
_client是实现IClient接口的一个类的对象。 -
在运行时,SUT(系统单元)是由启动类实例化的。由第三方库提供的
Client被传递给 SUT。这个实现了IClient的Client提供了一种从 OpenWeather 获取真实天气数据的方式。 -
单元测试不应该扩展到第三方,而应该限制在 SUT 上。
-
为了绕过调用真实服务,我们模拟了一个类,并将其命名为
ClientStub,并实现了IClient。ClientStub包含了一个生成虚构天气数据的实现。 -
我们按照单元测试的命名规范和 AAA 结构编写了我们的单元测试。
-
我们 SUT 构造函数需要一个
IClient的实例,因此我们向其中传递了ClientStub。 -
现在,我们可以测试我们的 SUT(系统单元)。
存根满足了我们之前提到的那些测试替身的前三个要求。此外,借助间谍(spies)的帮助,它们还满足了第四个要求。
对于 GetReal 方法的其余单元测试,使用了相同的存根过程。一些团队使用存根作为主要的测试替身类型,而其他团队则更喜欢使用模拟,这自然引出了我们下一个话题。
模拟
模拟与存根有很强的相似性,但它们不是在常规编码中实现存根的实现,而是使用一种 技巧 来生成行为,而不需要实现一个完整的类。模拟使用第三方库来减少创建测试替身所需的编码量。
模拟库
使用模拟(mocks),你必须使用第三方库或者自己构建——但愿不要这样。对于 .NET,有两个流行的库是 Moq(发音为 mock you)和 NSubstitute。
-
Moq 从 2010 年开始流行起来。它大量依赖于 lambda 表达式,这使得它在当时比同行更简洁。如果你喜欢 lambda 表达式,那么 Moq 就是你的选择。
-
NSubstitute 也在 Moq 附近发布。它的重点是提供可读的模拟语法。
这两个库在功能上都很成熟,并且拥有庞大的在线社区。本书将使用 NSubstitute,但也会在附录中快速介绍 Moq。
要安装 NSubstitute,你可以进入单元测试项目目录并执行以下代码:
dotnet add package NSubstitute
dotnet add package NSubstitute.Analyzers.CSharp
第二行是可选的。它添加了 C# NSubstitute 分析器,该分析器使用 Roslyn 在编译期间添加代码分析,以检测可能的错误。它还添加了 VS 为你提供改进模拟代码提示的能力。
现在,你已经安装并准备好使用 NSubstitute 库了。
使用模拟的示例
模拟和存根可以互换使用,因此理解它们的一个好方法是从我们之前的存根实现开始。让我们以我们在存根中使用的相同示例为例,即测试GetReal。在那个例子中,我们使用存根作为测试替身。现在,我们使用模拟,所以我们取上面的相同测试,并将Arrange部分替换为以下内容:
// Arrange
…
//var clientStub = new ClientStub(today, realWeatherTemps);
var clientMock = Substitute.For<IClient>();
clientMock.OneCallAsync(Arg.Any<decimal>(),
Arg.Any<decimal>(), Arg.Any<IEnumerable<Excludes>>(),
Arg.Any<Units>())
.Returns(x =>
{
const int DAYS = 7;
OneCallResponse res = new OneCallResponse();
res.Daily = new Daily[DAYS];
for (int i = 0; i < DAYS; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = today.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day =
realWeatherTemps.ElementAt(i);
}
return Task.FromResult(res);
});
var controller = new WeatherForecastController(null!,
clientMock, null!, null!);
在使用存根时,我们编写了一个完整的类,以便我们可以实例化它,正如你可以在注释行中看到的那样。在模拟中,NSubstitute 的神奇方法Substitute.For从IClient创建了一个具体类,并在一行中完成了实例化。
然而,创建的对象clientMock没有为OneCallAsync提供任何实现,因此我们使用了 NSubstitute 方法来说明:无论传递给OneCallAsync方法的参数(Is.Any<>)是什么,都返回在提供的 lambda 中描述的内容。lambda 的内容与之前在ClientStub中使用的内容相同。
我们仅用几行代码就动态地将一个方法实现附加到了我们刚刚创建的对象上。这相当令人印象深刻,并且比之前的存根对应物代码更少。模拟库有创建抽象的具体实现的能力,在高级场景中,它们可以模拟具体类并替换其部分实现。
当然,如果你正在使用模拟,那么在模拟示例中我们使用的ClientStub模拟类就不再需要了。你只需选择其中一个即可。
我已经创建了一个名为WeatherForecastControllerTestsWithMocking的测试类,以区别于使用存根的那个。在实际项目中,你通常不会这样做,因为你将典型地使用存根或模拟。本章和第二部分,使用 TDD 构建应用程序,将包含数十个使用模拟的示例。
间谍
当涉及到模拟时,我们很少使用术语间谍,因为间谍功能始终嵌入在模拟框架中。在存根中进行间谍活动是需要你编写的,而对于模拟,间谍功能是内置的。为了说明这一点,最好的方法是拿我们之前提供的带有存根的间谍示例,并将其改为使用模拟:
public async Task
GetReal_RequestsToOpenWeather_MetricUnitIsUsed()
{
// Arrange
// Code is the same as in the previous test
// Act
var _ = await controller.GetReal();
// Assert
await clientMock.Received().OneCallAsync(
Arg.Any<decimal>(), Arg.Any<decimal>(),
Arg.Any<IEnumerable<Excludes>>(),
Arg.Is<Units>(x => x == Units.Metric));
}
Arrange和Act部分没有变化;我们只是忽略了Act阶段的输出。变化的是我们的断言。NSubstitute 提供了一个名为Received的方法来监视传递的参数,并将其与Arg.Is结合,以验证传递了什么。
这是第一个Assert部分没有使用 xUnit 的Assert类的示例。这是完全合法的,因为Received()方法本身就是一个断言方法。
使用模拟的优缺点
模拟对象生成的代码简洁。如果我们把它们与存根比较,它们比普通代码(没有模拟库的代码)稍微难读一些。
模拟对象的缺点是,你依赖于像 NSubstitute 这样的库,并且与这个库相关的学习曲线。此外,一些实践者不喜欢模拟库用来动态附加行为的魔法,他们更愿意通过使用普通代码(存根)来使事情更明显。
接下来,我将总结模拟对象和存根之间的区别。
模拟对象与存根的比较
模拟对象和存根之间的区别很重要,因为你需要具备逻辑来选择最适合你的最佳技术。以下是一个快速的区别列表:
-
模拟对象和存根都被归类为测试替身,你可以根据项目需求或团队偏好选择使用其中之一,尽管在业界,模拟对象的使用频率比存根高。
-
模拟对象是通过像 Moq 或 NSubstitute 这样的第三方库实现的,而存根则不依赖于库。
-
模拟对象比存根更简洁,但它们的语法比普通代码稍微难读一些。
-
模拟对象被认为具有一些魔法,一些实践者认为这会破坏单元测试,而存根则是没有魔法的普通代码。
重要提示
模拟对象和存根之间的区别是一个流行的面试问题。同时,也很重要扩展答案并提到它们都是测试替身类型,主要用于单元测试。
上一个场景的回顾
回顾一下,我们与存根有相同的场景,但在存根时,我们添加了一个类来包含我们的存根,并在单元测试中使用它。在模拟中,我们使用了模拟框架,它促进了在单元测试体中包含我们的实现。
模拟对象满足了我们上面所提到的所有测试替身的要求。我希望之前的例子已经让你对模拟有了初步的了解。接下来,我们将探讨另一种测试替身类型。
存根
存根是模仿现实生活等效部分或全部的库,它们的存在是为了方便测试。
重要提示
“存根”这个术语在业界有多个定义。本章使用 Martin Fowler 的定义(martinfowler.com/bliki/TestDouble.xhtml),如下:“存根对象实际上有工作实现,但通常采取一些捷径,使得它们不适合生产(内存测试数据库是一个很好的例子)。”
一个令人困惑的名称是.NET 框架中的 Microsoft Fakes,它实现了隔离!
最受欢迎的示例库之一是FakeItEasy,它实现了模拟。此外,微软有一个名为 Microsoft Fakes 的框架,它实现了隔离!
.NET 库中最受欢迎的模拟对象示例之一是 Entity Framework Core 内存数据库提供者。这是来自微软文档的引用(docs.microsoft.com/en-us/ef/core/providers/in-memory):
此数据库提供者允许 Entity Framework Core 使用内存数据库。内存数据库对于测试很有用,……。内存数据库仅设计用于测试。
在内存中存储时,在执行每个单独的单元测试时很容易擦除和重新创建存储。这有助于在不担心数据状态变化的情况下重复测试。尽管如此,如果存储在磁盘上,例如使用真实的数据库(SQL Server、Cosmos、Mongo 或其他),则在每次测试之前重置数据并不是一个简单的任务。内存数据库的易失性特性非常适合单元测试。
如果 测试 A 将用户名从 JohnDoe 更改为 JohnSmith,而 测试 B 尝试将 JohnDoe 更改为 JaneSmith,如果 测试 A 所做的更改是永久的(持久到物理磁盘数据库),则 测试 B 一定会失败。使用易失性内存数据库可以在每次测试之间更容易地重置数据。这是一个重要的单元测试原则,称为 无依赖性。
模拟对象旨在帮助提供一个复杂系统的实现,以尝试使你的单元测试更加真实。如果你有一个使用关系型数据库并依赖于 EF Core 的系统,那么之前的提供者可能在单元测试时有所帮助:

图 4.3 – 内存存储与生产存储对比
模拟对象满足了我们之前提到的那些测试替身的前三个要求。如果模拟对象嵌入监视行为,它们将满足第四个要求。
在 第二部分,使用 TDD 构建应用程序 中,我们将使用此提供者,并展示模拟对象的使用。
隔离
隔离不是你在 TDD 中做的事情,但我为了完整性在此添加了有限的介绍。隔离完全绕过传统的依赖注入,并使用一种称为 shim 的不同依赖注入技术。Shim 涉及在运行时修改编译代码的行为以进行注入。
由于隔离框架的功能复杂,.NET 中这样的框架并不多。以下是可能仅有的两个可用于 .NET Core 的框架:
-
Microsoft Fakes:随 Visual Studio 企业版提供
-
Telerik JustMock:一个第三方商业工具。它还有一个名为 JustMock Lite 的开源受限实现。
我不知道有一个功能齐全的隔离库具有针对 .NET 5 及以上版本的许可,且许可为宽松的或免费的。
隔离框架主要用于单元测试遗留系统,在这些系统中,你无法更改生产代码以支持依赖注入。因此,隔离框架在运行时将依赖项注入到 SUT 中。它们不用于 TDD 的原因是,TDD 是关于在逐渐修改生产代码的同时添加测试,而隔离并不是为了修改生产代码。尽管你可以使用隔离框架来对绿色地带项目进行单元测试,但它们并不是这项工作的最佳工具。
虽然存在隔离框架来针对遗留代码,但我认为将单元测试应用于无法更改的代码并不是团队时间最佳的使用方式。我在第十二章《处理棕色地带项目》中对此进行了更详细的介绍。
我应该使用什么来进行 TDD?
让我们从排除法开始。隔离和隔离框架在 TDD 的上下文中可能不会被使用,因为它们是不兼容的。
占位符可以与所有类型的测试替身共存。在大多数单元测试中使用NullLogger<>服务(其中日志记录器未被使用)或传递 null 参数的情况将会发生。因此,当你能够使用占位符时,请使用它;实际上,如果可能的话,使用占位符应该优先于其他类型。
团队通常使用模拟或存根,但除非项目处于从一种状态过渡到另一种状态的过程中,否则不会同时使用两者。关于哪个更好的争论无法在本书中解决,它遍布整个互联网。然而,鉴于存根更难维护并且需要手动构建,模拟可以作为起点做得更好。从模拟开始,积累经验,然后你可以决定存根是否更适合你。
寻找合适的模拟对象往往是一种试错的过程。有时,你可以找到一个实现良好的模拟对象,比如 EF Core 内存数据库提供者,有时你可能会找到一个开源的模拟对象用于某些流行的系统。有时,你可能不幸,不得不自己创建一个。但是,模拟对象通常与模拟或存根一起使用;正如我们在本书的第二部分中将要看到的,它们不是单独使用一个或另一个。它们为你的测试增加了价值,你需要根据具体情况决定何时使用它们。
总之,对于不应成为 SUT(系统单元测试)一部分的对象或未使用的依赖项,使用占位符。为了构建和测试依赖项,使用模拟。在合适的地方添加模拟对象。
存根、模拟和模拟对象的意义可能会有所不同,定义也模糊不清。我尽量使用了行业中最常见的术语。重要的是理解我们可以使用的所有测试替身选项,并适当地使用它们。
测试替身是使单元测试与其他测试类别不同的因素。当我们讨论其他测试类别以更好地理解单元测试的独特性时,这一点可以进一步阐明。
更多测试类别
你可能已经听说过很多除了单元测试之外的测试类别。有 集成测试、回归测试、自动化测试、负载测试、渗透测试、组件测试——等等,清单还可以继续。好吧,我们不会涵盖所有这些测试类别,因为解释它们所有内容将不适合这本书。我们将要做的是讨论与单元测试有共同之处的两个类别。第一个是 集成测试,第二个是我称之为 S 集成测试 的测试。我们还将对 验收测试 表示敬意,因为它在构建完整的测试类别套件中非常重要。
单元测试、集成测试和 S 集成测试之间有一个主要区别,那就是它们处理依赖关系的方式。了解这些区别将有助于阐明单元测试如何在测试生态系统中定位。
集成测试
幸运的是,集成测试很容易理解。它就像单元测试一样,但使用的是真实依赖项,而不是测试替身。集成测试执行一个端点,例如一个方法或 API,这将触发所有真实依赖项,包括外部系统,如数据库,并针对标准测试结果。
示例
xUnit 框架也可以执行集成和 S 集成测试,所以为了展示一个例子,我们可以以创建单元测试项目相同的方式创建一个集成测试项目。从你的控制台,进入你的解决方案目录,并执行以下操作:
dotnet new xunit -o Uqs.Weather.Tests.Integration -f net6.0
dotnet sln add Uqs.Weather.Tests.Integration
我们刚刚创建了一个使用 xUnit 框架 的集成测试项目,并将其添加到我们的解决方案中。我们的集成测试将通过 HTTP 进行,并将反序列化 JSON 值,因此我们需要这样做:
cd Uqs.Weather.Tests.Integration
dotnet add package System.Net.Http.Json
这将为你的集成测试添加 .NET JSON NuGet 包。
重要提示
注意到集成测试项目没有引用 Uqs.Weather。这是因为集成测试项目将通过 HTTP 触发 RESTful API,不需要使用 Uqs.Weather 中的任何类型。
在这个例子中,我们想要测试从第二天开始获取 5 天:
private const string BASE_ADDRESS = "https://localhost:7218";
private const string API_URI = "/WeatherForecast/
GetRealWeatherForecast";
private record WeatherForecast(DateTime Date,
int TemperatureC, int TemperatureF, string? Summary);
在类级别上,我们添加这些字段。它们指定了服务的地址,指向我的本地机器,并指定了 SUT 的 URI。通过查看 Uqs.Weather 中的 WeatherForecast 类,我知道我正在返回一个包含五个字段的 WeatherForecast 数组。因此,我构建了一个与从 RESTful API 调用中预期返回的数据相似的记录。
我的集成测试看起来像这样:
public async Task
GetRealWeatherForecast_Execute_GetNext5Days()
{
// Arrange
HttpClient httpClient = new HttpClient
{ BaseAddress = new Uri(BASE_ADDRESS) };
var today = DateTime.Now.Date;
var next5Days = new[] { today.AddDays(1),
today.AddDays(2), today.AddDays(3),
today.AddDays(4), today.AddDays(5) };
// Act
var httpRes = await httpClient.GetAsync(API_URI);
// Assert
var wfs = await
httpRes.Content.ReadFromJsonAsync<WeatherForecast[]>();
for(int i = 0;i < 5;i++)
{
Assert.Equal(next5Days[i], wfs[i].Date.Date);
}
}
我们不知道测试将在哪一天执行,所以我们正在获取今天的日期,然后计算出接下来 5 天的日期。我们正在创建和设置一个 HttpClient 来发起 HTTP 请求。
在 Act 中,我们正在调用 RESTful API 的端点。
在 Assert 中,我们将从 JSON 返回的值转换为之前创建的 record 类,并检查我们是否得到了接下来的 5 天。
这个测试需要与之前运行单元测试的方式不同的设置来运行。这是一个进程外测试,这意味着 API 在一个进程中运行,而测试在另一个进程中运行。这两个进程通过 HTTP 相互通信。因此,要运行这个测试,我们首先需要启动 REST API 进程。右键单击Uqs.Weather | 调试 | 不调试启动。然后这将在控制台窗口中启动 Kestrel web 服务器,并使我们的 API 准备好进行 HTTP 调用。
现在,你可以像执行单元测试一样执行集成测试。
由这个测试触发的活动
我们在集成测试中刚刚执行的 API 调用触发了多个依赖项以生成输出。以下是触发的一些依赖项:
-
集成测试和 ASP.NET Web API 宿主之间的网络,包括 HTTPS 连接
-
启动并显示在控制台窗口中的 ASP.NET Web API 宿主进程
-
分析请求并在控制器中触发正确操作方法的路由代码
-
决定创建和注入哪个对象的 DI 容器
-
Uqs.Weather和OpenWeather API 之间的 HTTPS 连接
我们知道每个依赖项都能独立工作。通过执行这个测试,我们确保了所有组件都已集成并且能良好地协同工作。以下图表显示了执行测试时发生的一些情况:

图 4.4 – 请求/响应通过依赖项
这些并不是所有组件,因为我遗漏了一些,但希望你能理解这个概念。
注意事项
这是本书中的第一个(也是最后一个)集成测试,我想指出这个测试与其单元测试等价物的区别:
-
我们无法提前知道日期——我们必须动态确定日期,而我们的单元测试中在
Assert部分有一个预配置的日期。 -
没有测试替身,所有东西都在执行真实对象。
-
我们运行了两个进程;一个是托管 API 的 web 服务器,另一个是我们的集成测试。单元测试是直接、进程内调用的。
-
存在一种与测试无关的失败概率。测试可能会因为以下原因而失败:
-
防火墙问题
-
OpenWeather服务已关闭
-
超出了我们OpenWeather许可证允许的调用次数
-
ASP.NET Web 服务器尚未启动
-
路由模块配置不正确
-
其他环境问题
-
-
这个测试虽然未进行测量,但因为它需要在两个进程之间进行通信,击中多个组件,然后通过 HTTPS 进行,这包括序列化/反序列化和加密/解密,所以它将花费更长的时间。尽管执行时间不明显,但 10 秒的集成测试时间也会累积。
单元测试与集成测试
集成测试和单元测试是优秀的工具,比较它们可能意味着使用其中一个而不是另一个。事实并非如此,因为它们是互补的。集成测试擅长测试完整周期的调用,而单元测试擅长测试业务逻辑的各种场景。它们在质量保证中扮演着不同的角色;问题出现在它们相互干扰时。
重要提示
单元测试与集成测试的区别是常见的面试问题,这允许面试官评估候选人是否理解依赖管理、测试替身、单元测试和集成测试。
集成测试相对于单元测试的优势
这里有一些优势可能会让我们选择使用集成测试而不是单元测试:
-
集成测试检查的是真实的行为,这模仿了最终用户可能执行的操作,而单元测试检查的是开发者认为系统应该执行的操作。
-
集成测试更容易编写和理解,因为它们是常规代码,不使用测试替身,也不关心依赖注入(DI)。
-
集成测试可以覆盖单元测试无法高效覆盖的场景,例如整个系统组件之间的集成以及 DI 容器注册。
-
集成测试可以应用于遗留系统或绿色系统。事实上,集成测试是测试遗留系统的一种推荐方法,而单元测试需要代码重构才能引入到棕色项目中。
-
一些集成测试,如上面的例子,可以以语言无关的方式编写。因此,之前的测试可以是用 F#、Java 或 Python 编写的,或者由 Postman 等工具编写,而单元测试使用与生产代码相同的语言(在我们的例子中是 C#)。
单元测试相对于集成测试的优势
这里有一些优势可能会让我们选择使用单元测试而不是集成测试:
-
单元测试执行得更快,这在运行数百个测试并寻找短反馈循环时非常重要,尤其是在集成代码或发布到环境之前。
-
单元测试的结果可预测,不受时间、第三方服务可用性或环境间歇性问题的干扰。
-
单元测试是可重复的,因为它们不会持久化任何数据,而集成测试可能会永久更改数据,这可能会使后续测试不可靠。这种情况发生在编写和编辑过程中。我们上面的例子是读取(获取),所以它没有受到这个问题的影响。
-
单元测试更容易部署到 CI/CD 管道中(我们将在第十一章,*使用 GitHub Actions 实现持续集成)中演示这一点)。
-
在单元测试中找到错误比在集成测试中找到相同的错误要早,并且可以更快地定位。
-
单元测试可以在功能开发期间运行,而集成测试只能在功能完全准备好时添加。
混淆单元测试和集成测试
在各种集成测试实现中使用了诸如 xUnit 或 NUnit 之类的框架。框架名称中的“单元”一词可能会误导一些开发者,使他们认为这些项目中写的是单元测试。再加上使用AAA 约定和方法名称约定,这也可能造成误导。实际上,我在之前的集成测试中使用了这些约定,但使用与单元测试相同的约定并不意味着测试就是单元测试。
根据要实施的测试类型,设置基础设施和构建 CI 管道会有所不同。尽管它们看起来相同,但区分它们对于理解所需的任务和维护级别非常重要。
由于它们看起来相同,如何区分它们?有一个明显的标志,如果它们不依赖于真实依赖项,那么它们很可能是单元测试。如果它们使用会触发真实外部依赖项的真实对象,那么它们很可能不是单元测试。
S 集成测试
S 集成测试是集成测试和单元测试之间的中间阶段。集成测试依赖于真实组件,而单元测试依赖于测试替身。S 集成测试试图通过混合单元测试的元素来解决集成测试的不足:

图 4.5 – 单元、S 集成和集成测试
我发现一些开发者将这种测试称为组件测试。但在软件工程中,组件测试意味着不同的东西,我觉得开发者们——正确地——更关心它所做的事情,而不是正确命名。这个测试类别具有以下独特的特征,如下所述:
-
用它们的真实对应物替换一些依赖项
-
通过构建替身(测试替身的替身)来模拟一些真实依赖项
对于“s”ubstitute、“s”wap 和“s”imulate 开头的“S”,以及它对集成测试的相似性,我给它命名为S 集成测试。就像往常一样,让我用一个例子来澄清 S 集成测试。
示例
假设我们有一个使用日志记录、服务总线队列和 Cosmos DB 的 Web 项目。日志记录到云端,因此需要云连接。队列也是一个云组件,Cosmos DB 同样也是。
让我们再假设我们有一系列 API 来处理用户资料,例如UpdateName API 和ChangePassword API。S 集成测试可以执行以下操作:
-
使用 Kestrel Web 服务器,并具有与生产环境相同的功能,因为 Kestrel 足够灵活,可以在本地机器、测试和生产环境中按需运行。
-
写日志需要访问云,因此我们注入一个
NullLogger<>服务,该服务将忽略日志记录,但允许系统运行。 -
队列仅在云上可用,所以我们用可以轻松在测试之间擦除的模拟内存队列来替换它。
-
Cosmos DB 没有内存实现,但云版本可以在测试运行之间轻松擦除。因此,我们使用相同的 .NET Cosmos DB 客户端库,但在测试时指向不同的数据库——Sintegration 测试 Cosmos DB。
下面是这个系统看起来像的项目组件图:

图 4.6 – 执行 Sintegration 测试
在这种场景中,Sintegration 测试使用模拟、替身和真实组件。它们覆盖了系统中的部分集成,另一方面,队列和数据库可以在各个测试之间轻松擦除,以确保没有 Sintegration 测试影响其他测试。
Sintegration 测试近年来开始受到关注;也许从经典的 .NET Framework 到 .NET 的转变使这种测试类型浮出水面,因为 .NET Core 库不再依赖于特定的 Windows 组件,例如 IIS 服务器。此外,ASP.NET Core 有特定的实现允许这种测试,而过去这些实现不是框架的一部分。这些实现之一是 .NET 中的 Kestrel 服务器作为一部分,并且可以轻松启动而无需依赖特殊的部署。
这是对 Sintegration 测试的简要概述。虽然 Sintegration 测试不是 TDD 的一部分,但了解它们很重要,因为它们与单元测试相关。
验收测试
验收测试是对完整功能或特性的端到端测试。用于网站的验收测试的一个流行工具是Selenium。您可能会在不同的名称下找到这类测试,例如功能测试、系统测试和自动化测试。
示例
这是一个模拟用户更新其姓名、然后点击更新按钮,接着检查姓名是否已更新的测试。此示例测试了不同动作的完整工作流程,可能按照用户触发的顺序进行。
将验收测试视为多个依次执行的集成测试。这些测试脆弱且缓慢,因此最好保持最少。另一方面,在系统中它们是必要的,因为它们覆盖了单元测试和 Sintegration 测试未覆盖的领域。
选择测试类别
在阅读了关于三种测试类型——即单元、Sintegration 和集成——以及额外的验收测试之后,你应该编写哪些测试?出人意料的是,答案是所有四种(如果可能的话)。然而,如果同时实现了 Sintegration 和验收测试,则可以省略集成测试。
测试三角形
有一个行业概念称为测试三角形,它说明了要实施的必要测试类别以及每个类别中的测试数量。然而,与所有软件工程概念一样,必要测试的类别在每个三角形中都是不同的。让我们看看绿色地带项目的测试三角形:

图 4.7 – 测试三角形
前面的图像中的三角形提倡比其他两个测试类别更多的单元测试。假设你已经熟悉了提到的三种测试。你会发现,与其他两种测试相比,实现单元测试所需的时间最少,并且执行数百个单元测试只需要几秒钟(编译和加载代码之后)。
关于这个三角形与棕色地带项目的讨论将在第十二章“处理棕色地带项目”中介绍。希望关于集成测试和 S 集成测试的讨论能帮助你更好地理解单元测试是什么。
摘要
在本章中,我们比较了单元测试与其兄弟姐妹:集成和S 集成测试。我们列出了测试替身,并为每个提供了示例,我们还看到了 xUnit 和 NSubstitute 的实际应用。
我们理解单元测试和测试替身之旅不会在这里停止,但我们将在这本书的其余部分涵盖更多这两个主题的例子。
到目前为止,你可以将本章的经验应用到 TDD 的 5 个级别中的第 3 个级别!现在,你应该能够编写一个使用测试替身的简单单元测试。
我们没有涵盖单元测试的优点和缺点——是的,它有缺点!我们也没有涵盖 TDD 如何与单元测试相关以及单元测试的最佳实践,因为这是下一章测试驱动开发解释的作用。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下链接:
-
马丁·福勒对测试替身的定义:
martinfowler.com/bliki/TestDouble.xhtml -
NSubstitute:
nsubstitute.github.io
第五章:测试驱动开发解释
测试驱动开发(TDD)是一组在单元测试之上的实践。它们改变了你设计代码和编写单元测试的方式。基本上,这是一种与传统的编写代码然后测试它不同的编写代码的方法。
说 TDD 不仅仅是先做测试是一种陈词滥调,但与其让我告诉你相反,你将在阅读第五章和第六章之后自己做出决定。
在本章中,我们将:
-
通过 TDD 支柱
-
按照 TDD 风格实现软件功能
-
转换关于该主题的常见问题解答和批评
-
讨论 TDD 与 Sintegration 测试的结合
到本章结束时,你将能够使用 TDD 来编写基本的编码任务,并理解该主题的相关内容以及 TDD 在软件生态系统中的位置。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
TDD 支柱
TDD 是一组实践,它指定了何时以及如何编写单元测试。你可以不使用 TDD 编写单元测试,但 TDD 必须与某种类型的测试相关联。有时,你可以听到 TDD 和单元测试被用作似乎它们意味着同一件事,但实际上它们不是。
虽然围绕 TDD 的生态系统非常复杂,因为它涉及到许多软件工程方面,但作为一个独立的概念,TDD 很容易解释和理解。我们可以将 TDD 总结为以下两个支柱:
-
测试先行
-
红色、绿色、重构(RGR)
让我们讨论这些支柱。
测试先行
这里的想法是在开始编写生产代码之前先编写测试。这实际上意味着测试尚未存在的代码!
测试先行改变了我们编写代码的方式,因为现在你需要在实现之前先考虑你的类的结构和公共方法。这鼓励开发者从客户端的角度(客户端是调用你的代码的外部代码,也称为调用者)来反思设计。
我们将通过几个示例来展示如何从测试开始,让你熟悉这个概念,并了解它是如何进行的。然后,在第六章的第一节中,我们将详细介绍这种方法的益处,即TDD 的 FIRSTHAND 指南。
红色、绿色、重构
RGR 过程是在 TDD 风格的代码编写中使用的。这个过程与测试先行的指南协同工作。以下是具体步骤:
-
你计划编写一个编码任务,这是你想要添加到代码中的功能的一部分。
-
当生产代码不存在(因为你还没有编写它)时,你编写这个生产代码的单元测试。也许你计划更新现有的生产代码,所以你先编写一个单元测试,假设最终的生产代码已经到位。
-
你运行单元测试,它将失败(红色),原因之一:
-
代码无法编译,因为尚未编写生产代码。
-
测试将失败,因为在编译过程中,实现新编码任务的逻辑是错误的,因为现有的生产代码尚未更新以反映新功能。
-
-
编写最快且最少的代码以使测试通过(绿色)。在这个阶段不要完善代码;你也可以从互联网上复制预期的代码。想法只是继续前进。
-
现在,我们知道我们的编码任务已经就绪并且正在工作;然而,如果你认为生产代码存在以下问题之一,你可能需要重构它:
-
可读性
-
性能
-
不符合其余代码的设计
-
-
在重构时运行你的单元测试以确保你没有破坏任何东西。如果它被破坏(红色),那么你自然会回到 步骤 3。
你可以理解为什么选择了这些颜色名称:
-
红色:表示失败的测试,在测试运行器(如 VS 测试资源管理器)中显示为红色
-
绿色:表示通过的测试,在测试运行器中显示为绿色

图 5.1 – 红色、绿色、重构过程
之前的图示突出了我们在 RGR 过程中刚刚讨论的步骤。
我们可以绘制图表并进一步讨论 TDD,或者我们可以通过示例进行演示,这就是我们接下来要做的。
通过示例进行 TDD
通过查看示例,我们可以更好地理解 TDD,所以让我们拿一个故事并以 TDD 风格编写它。我们将致力于这个故事描述的功能:
故事标题:
更改用户名
故事描述:
作为一个客户
给定我已经有一个账户
当我导航到我的个人资料页面
然后我可以更新我的用户名
验收标准:
用户名只能包含 8 到 12 个字符,包括:
- 有效:AnameOf8, NameOfChar12
- 无效:AnameOfChar13, NameOf7
只允许使用字母数字和下划线:
- 有效:Letter_123
- 无效:!The_Start, InThe@Middle, WithDollar$, Space 123
如果用户名已存在,则生成错误
让我们不要浪费时间,实现这个故事。
创建解决方案外壳
我们将创建一个名为 Uqs.Customer 的类库,添加一个名为 Uqs.Customer.Tests.Unit 的单元测试项目来测试它,并将它们添加到名为 TddByExample.sln 的解决方案中。所以,让我们开始:
-
在名为
TddByExample的目录中创建lib类和以下 xUnit 项目:dotnet new classlib -o Uqs.Customer -f net6.0 dotnet new xunit -o Uqs.Customer.Tests.Unit -f net6.0 -
创建一个解决方案文件并将项目添加到其中。解决方案名称将是目录名称。因此,在这种情况下,它将自动命名为
TddByExample.sln:dotnet new sln dotnet sln add Uqs.Customer dotnet sln add Uqs.Customer.Tests.Unit -
从单元测试项目引用生产代码项目:
dotnet add Uqs.Customer.Tests.Unit reference Uqs.Customer -
当你打开解决方案时,你会看到以下内容:

图 5.2 – 显示新创建项目的解决方案资源管理器
现在,进入编码阶段。
添加编码任务
请求的功能包括更小的编码任务(挑战),因此你将添加多个单元测试,每个编码挑战至少有一个单元测试。
我从提供最短反馈的挑战开始,逐步进行。这将帮助我构建初始结构,而无需同时担心所有事情。它还将帮助我避免从开始就处理诸如数据库连接和数据库中用户名可用性(用户名已被占用)等任务。然后,按复杂度顺序添加更复杂的挑战。让我们看看我们如何实现这一点。
编码任务一 - 验证空用户名
我通常花很少的时间分析最短代码挑战,并依靠直觉。我觉得最短的路径是检查用户名的空值。现在,将模板测试类从UnitTest1.cs重命名为ProfileServiceTests.cs,并用以下代码替换内容:
namespace Uqs.Customer.Tests.Unit;
public class ProfileServiceTests
{
[Fact]
public void
ChangeUsername_NullUsername_ArgumentNullException()
{
// Arrange
var sut = new ProfileService();
// Act
var e = Record.Exception(() =>
sut.ChangeUsername(null!));
// Assert
var ex = Assert.IsType<ArgumentNullException>(e);
Assert.Equal("username", ex.ParamName);
Assert.StartsWith("Null", ex.Message);
}
}
之前的代码为将null传递给我们的目标方法做了准备。它执行了方法并记录了由方法生成的Exception。
最后,我们检查是否得到了一个具有username作为参数且消息以Null开头的ArgumentNullException类型的异常。
在执行此操作之前,让我们回顾一下到目前为止发生的事情。
命名你的测试类
立刻,我必须做出的决定是思考我的生产代码类名,因为我需要它来附加Tests后缀并创建我的测试类名(记住约定:ProductionCodeClassTests)。我在我的架构中遵循领域驱动设计(DDD),所以我直接将其视为一个服务类。现在不必担心 DDD 术语以及它与 TDD 的匹配,因为 DDD 将有一个专门的章节,第七章,领域驱动设计的实用视角。
我没有对我的类命名进行深入思考,因为我总是可以轻松地重命名我的类。我选择了ProfileService,因此我可以将我的单元测试类命名为ProfileServiceTests。请注意,在此阶段我尚未创建ProfileService。
注意
一些开发者编写了没有意义的测试类名,然后在完成第一个单元测试后将其重命名。做让你更有效率的事情。这不是一个僵化的过程。
单元测试方法命名
当我想编写测试时,我需要思考我在测试什么以及我应该期待什么,遵循MethodName_Condition_Expectation方法。因此,我选择了方法名为ChangeUsername,条件为检查null。
提前决定你的期望
期望需要稍作停顿和思考。我期望调用此方法的任何人都没有发送null,因为他们已经在 UI 或其他方式上检查了它。所以,如果它不符合我的期望,我会无情地抛出异常,让客户端处理它。
这里的关键是,我直接从客户端的角度出发,并关注我打算方法的对外行为。
确保测试失败
在这个阶段,您可以看到 VS 使用波浪线突出显示您的代码,因此不需要天才就能得出结论,代码将无法编译,因为还没有编写任何生产代码。
您已经迈出了 RGR 的第一步,因为您已经得到了红色提示。
创建生产代码类壳
至少让我们能够编译以从 VS 获取一些智能感知帮助。因此,在您的 Uqs.Customer 项目中,将 Class1.cs 修改为 ProfileService.cs,内容将类似于以下这样:
namespace Uqs.Customer;
public class ProfileService
{
}
然而,鉴于您将经常这样做,这里有一些快捷方式。例如,如果您有 ReSharper,那么它将为您提供根据您的单元测试生成生产代码的选项。在 VS 中,而不是像上一步那样将 Class1.cs 重命名为 ProfileService.cs,只需删除它,然后按照以下步骤执行相同操作:
- 将鼠标悬停在带有波浪线的
ProfileService()上,VS 将显示一个灯泡图标。展开灯泡并选择如图所示的 生成新类型…:

图 5.3 – 选择重构灯泡
注意
有时灯泡需要一段时间才会出现;您始终可以使用 Ctrl + . 快捷键强制其出现。
- 当对话框出现时,您可以按照以下方式更改其设置:

图 5.4 – 生成类型对话框
这样,您将得到与之前相同的 ProfileService.cs 类。
注意
从灯泡菜单中选择第一个选项,在新的文件中生成类‘ProfileService’,并不能完成这项工作,因为 VS 将在单元测试项目中生成文件,而您打算在生成代码项目中生成它。
现在我们已经创建了类壳,让我们继续我们的生产代码编写过程。
创建生产代码方法壳
要创建 ChangeUsername 方法,将鼠标悬停在它上面,并按照以下方式选择灯泡:

图 5.5 – 生成方法
它将在显示窗口中显示将要生成的内容。这正是我们想要的,因此选择 ProfileService 类:
public void ChangeUsername(string username)
{
throw new NotImplementedException();
}
这就是生成的代码。或者,您也可以自己编写代码。
注意
这是在使用 C# 10,因此没有 string?(在 string 后面有一个问号)作为参数会警告调用者该方法不期望 null。但是,调用者仍然可以强制 null。注意,单元测试在 Act 部分通过在 null 后面加感叹号来强制 null:sut.ChangeUsername(null``)。
注意,生成的代码已经为您添加了 NotImplementedException。
注意
使用NotImplementedException是一个好习惯,可以向读者突出显示代码尚未编写,并在不小心调用时抛出异常,以防你忘记了它并将其推送到源代码控制。
现在到了有趣的部分,实现。
编写空值检查逻辑
所有这些都是为了编写以下逻辑:
public void ChangeUsername(string username)
{
if (username is null)
{
throw new ArgumentNullException("username", "Null");
}
}
异常的第一个参数表示参数名,第二个表示错误信息。
从测试资源管理器运行单元测试(Ctrl + R, A),你应该能看到代码编译并且所有测试都通过了(绿色)。
重构
我在写完代码后看了看,觉得可以用以下方式改进:
在代码中使用魔法字符串来匹配我的参数名不是一种好习惯,因为每次我更改参数名时,字符串并不一定会随之改变。我将使用nameof关键字。
我重构的代码看起来如下:
throw new ArgumentNullException(nameof(username), "Null");
现在我已经做了这些更改,我再次运行了测试,并且它们通过了。
尽管重构的大小很小,并且通常在更简洁的示例中重构会更复杂,但这个示例很好地展示了你可以如何实现可选的重构。
我们已经完成了第一个编码任务!第一个任务通常比其他任务长,因为在第一个任务中,你将构建外壳并决定一些名称。我们的第二个任务将会更短。
编码任务二 – 验证最小和最大长度
再次,没有花太多时间思考第二个要测试的内容,我想到了长度验证,根据故事中用户名长度应在 8 到 12 个字符之间(包括 8 和 12),所以这是我的第二个单元测试,针对这个场景:
[Theory]
[InlineData("AnameOf8", true)]
[InlineData("NameOfChar12", true)]
[InlineData("AnameOfChar13", false)]
[InlineData("NameOf7", false)]
[InlineData("", false)]
public void ChangeUsername_VariousLengthUsernames_
ArgumentOutOfRangeExceptionIfInvalid
(string username, bool isValid)
{
// Arrange
var sut = new ProfileService();
// Act
var e = Record.Exception(() =>
sut.ChangeUsernam(username));
// Assert
if (isValid)
{
Assert.NullI;
}
else
{
var ex =
Assert.IsType<ArgumentOutOfRangeException>(e);
Assert.Equal("username", ex.ParamName);
Assert.StartsWith("Length", ex.Message);
}
}
之前的代码为有效和无效长度的用户名准备了多个测试场景。它使用Theory属性将多个场景传递给单元测试。最后,我们检查是否得到了ArgumentOutOfRangeException类型的异常。我们使用if语句进行分支,因为有效的用户名不会产生异常,所以我们将会得到null。
备注
一些实践者反对在单元测试中包含任何逻辑,例如if语句。我属于另一所学校,认为在单元测试中包含清晰易读的轻量级逻辑可以减少重复。做对你和你的团队来说可读性最好的事情。
这个示例测试数据可能来自编写故事的人(例如,产品所有者、业务分析师或产品经理),也可能是你,或者两者的组合。
红色阶段
运行单元测试,这个新添加的单元测试应该会失败,因为我们还没有编写任何实现。
绿色阶段
将以下逻辑添加到你的方法中:
if (username.Length < 8 || username.Length > 12)
{
throw new ArgumentOutOfRangeException
("username","Length");
}
从测试资源管理器运行单元测试(Ctrl + R, A),你应该能看到代码编译并且所有测试都通过了。我们已经达到了绿色状态。
重构阶段
我在写完代码后看了代码,觉得可以用以下方式改进:
我将长度比较了两次。幸运的是,C# 8 引入了模式匹配,这将导致更易读的语法(可以说是)。此外,C# 可能会进行一些优化魔法,以防止Length属性被执行两次。
我重构的代码看起来如下:
if (username.Length is < 8 or > 12)
{
throw new ArgumentOutOfRangeException(
nameof(username), "Length");
}
现在我做了这些更改,再次运行了测试,并且它们通过了。
编码任务三 – 确保只包含字母数字和下划线
根据要求,我们只允许字母数字和下划线,所以让我们为这个编写测试:
[Theory]
[InlineData("Letter_123", true)]
[InlineData("!The_Start", false)]
[InlineData("InThe@Middle", false)]
[InlineData("WithDollar$", false)]
[InlineData("Space 123", false)]
public void
ChangeUsername_InvalidCharValidation_
ArgumentOutOfRangeException
(string username, bool isValid)
{
// Arrange
var sut = new ProfileService();
// Act
var e = Record.Exception(() =>
sut.ChangeUsername(username));
// Assert
if (isValid)
{
Assert.Null(e);
}
else
{
var ex =
Assert.IsType<ArgumentOutOfRangeException>(e);
Assert.Equal("username", ex.ParamName);
Assert.StartsWith("InvalidChar", ex.Message);
}
}
运行测试,除了Letter_123的第一个测试是有效测试外,其他都应该失败。我们希望一切失败以确保我们没有犯错误。这是测试资源管理器的输出:

图 5.6 – 对有效字母测试的测试资源管理器输出
你可以通过以下两种解决方案之一使测试失败:
-
前往生产代码并编写将使此测试失败的代码。我个人不喜欢这种方法,因为它感觉是一种纯粹的方法,但其中并没有什么错误。
-
调试代码并查看为什么它没有实现却通过了。这是我采取的方法,而且看起来这确实是一个有效场景,所以它应该通过。我可以忽略通过测试并假设一切都已经失败。
那么,让我们编写正确的实现。
注意
你可以看到在我们的所有测试中,我们不仅断言是否有异常,还断言异常的类型和两个异常字段。这种方法将帮助我们捕获我们正在寻找的特定异常,并避免捕获由其他原因引起的其他异常。
最快的方法是使用一个只允许字母数字和下划线的正则表达式。最快的方法是在网上搜索alphanumeric and underscore only C# regex。我在 StackOverflow 上找到了这个正则表达式,看起来是这样的:^[a-zA-Z0-9_]+$。
记住,我的意图是尽可能快地使这个通过,而不太考虑代码或对其进行润色。这是新代码:
if (!Regex.Match(username, @"^[a-zA-Z0-9_]+$").Success)
{
throw new ArgumentOutOfRangeException(nameof(username),
"InvalidChar");
}
再次运行测试,它应该会通过。
然而,代码存在性能问题,因为内联正则表达式很慢。让我优化性能并提高可读性。这是重构后的整个类:
using System.Text.RegularExpressions;
namespace Uqs.Customer;
public class ProfileService
{
private const string ALPHANUMERIC_UNDERSCORE_REGEX =
@"^[a-zA-Z0-9_]+$";
private static readonly Regex _formatRegex = new
(ALPHANUMERIC_UNDERSCORE_REGEX, RegexOptions.Compiled);
public void ChangeUsername(string username)
{
if (username is null)
{
throw new ArgumentNullException(nameof(username),
"Null");
}
if (username.Length is < 8 or > 12)
{
throw new ArgumentOutOfRangeException(
nameof(username), "Length");
}
if (!_formatRegex.Match(username).Success)
{
throw new ArgumentOutOfRangeException(
nameof(username), "InvalidChar");
}
}
}
重构后再次运行。坦白说,我的测试失败了,因为我重构时遗漏了复制正则表达式中的一个字母。再次运行测试显示我的重构不正确,所以我修复了代码并再次尝试。
编码任务四 – 检查用户名是否已被使用
显然,检查用户名是否已被使用将需要访问数据库,并且为此进行测试将需要测试替身。此外,由于你正在进行 IO 操作(通过访问数据库),所有的方法都将遵循async await模式。
这个编码任务以及为完成这个功能而留下的其他编码任务将需要数据库访问,而我故意避免了这一点。我想让这一章让你熟悉 TDD,而不必通过测试替身和更高级的主题。在这本书的第二部分,使用 TDD 构建应用程序中,你将会有专门的章节,涉及 TDD、领域驱动设计(DDD)、测试替身和数据库的混合。所以,现在我们就到这里为止。否则,如果我解释了所有这些,我怎么能鼓励你继续阅读呢?
这就结束了本章的编码任务,我希望到目前为止你已经掌握了 TDD 的节奏。
概述
当开始一个新功能时,你需要把这个功能视为一系列编码挑战。每一个编码挑战都将从一个单元测试开始,类似于以下图示:

图 5.7 – 由任务组成的功能,每个任务都有一个针对该任务的单元测试
有时候,你可能还没有创建单元测试项目,所以你看到了如何创建一个。有时候,你将单元测试添加到现有的单元测试项目中,然后你可以立即开始添加测试。
你必须提前考虑你的客户端将如何与你的生产代码交互,并且你根据客户端的期望来设计一切。
你在添加每个编码任务时遵循了 RGR 模式,并且你已经看到了多个例子。
更高级的场景将在第二部分,使用 TDD 构建应用程序中介绍。
常见问题解答和批评
TDD 是现代软件开发中最具争议性的话题之一。你会发现一些开发者对其深信不疑,而另一些则声称它毫无用处。
我会尽量客观地回答问题,并在相关的地方展示两种观点。
为什么我需要做测试驱动开发(TDD)?难道我不能只做单元测试吗?
如你从本章的开头所了解的,TDD 是一种编写单元测试的风格。所以,是的,你可以在不遵循 TDD 风格的情况下编写单元测试。在下一章中,你将找到来自 FIRSTHAND 指南的第一条指导原则,它将专注于遵循 TDD 风格的好处。
我发现一些团队由于各种原因不愿意做 TDD。我的建议是,如果你的团队不愿意遵循 TDD,不要放弃单元测试。也许如果你从单元测试开始,那么下一个发展阶段就是 TDD。这可能会减缓某些团队的变化速度。
我之前说过吗?即使你不遵循 TDD,也不要放弃单元测试。
TDD 对软件开发过程来说感觉很不自然!
我相信当你第一次学习编程时,你的担忧是理解基本的编程结构,例如for循环、函数和面向对象编程(OOP)。你的担忧,或者你导师的担忧,并不是生产可扩展的高质量软件,因为你只是想要一个能工作的程序,其中可能存在一些错误。
当你在学习时,这行得通,这可能是你所说的自然,因为你从第一天开始就是这样做的。
在现实世界中,对软件的现代期望是:
-
可扩展性:基于云的解决方案成为常态,微服务接管了控制权。
-
自动化:手动测试过程最终变得过时。测试开发者成为了一个流行的职位,自动化测试成为现代趋势。
-
DDD:让对象以复杂的方式相互交互。
-
发布管道准备就绪(CI/CD):CI 在本书中有一个专门的章节。简而言之,你的软件应该允许增量功能添加并定期推送到生产环境。
上述场景是当今软件开发者的担忧,因此,你必须改变你的工作策略以适应新的现实生活规范。这需要你在编写代码的方式上发生范式转变,因此,TDD 成为开发规范。
进行 TDD 会让我们放慢速度!
在开始一个项目时,不进行任何形式的测试在短期内可能会带来更快的结果。考虑以下图表,它描述了这个概念:

图 5.8 – 关于时间和功能的 TDD 与无测试的比较
如果你从头开始构建没有依赖项和功能的软件,那么不进行测试很容易,甚至可能有人手动进行。项目规模小,易于管理,并且易于更改和部署。
在早期阶段,没有测试的软件开发会很快,直到你开始有一系列依赖的功能,其中更改一个功能会导致另一个功能出错!这就是测试支持的软件开始发光的地方,添加新功能引入现有功能错误的可能性更小,因为这将通过良好实现的测试来捕捉。我多次从产品负责人那里听到这个说法:当我们添加一个新功能时,另一个无关的区域会出错! 他们通常责怪让这个错误通过的开发商或测试员。对于非开发者来说,很容易认为功能之间没有关系,但你和我都知道情况并非如此。
显然,在临界点之后,未经测试的软件速度变慢的原因是功能不再易于管理。功能正在迅速变化,开发者正在转向不同的区域,甚至离开项目,新开发者正在取代他们。
所以,是的,TDD 可能会放慢你的速度,但这取决于你在开发过程中的位置。如果你已经通过了前面图表中的临界点,那么你将开始收获你的投资带来的好处。
TDD 对于初创公司来说相关吗?
参与初创企业的开发者通常压力很大,并且被功能请求淹没。公司的生存和资金可能取决于下一组功能。很少产品所有者会关心长期战略,因为如果初创企业的命运是危险的,为什么要费心呢?我们以后再担心明天吧。
如果初创企业在临界点之前失败,那么投资 TDD 是否有意义?但如果他们通过了这个临界点并且没有单元测试呢?也许当公司有资金和客户时,他们可能会重写代码库。他们将添加单元测试,或者也许他们不会,并且他们可能会在添加新功能时遇到困难。
启动情况很复杂;你可以从前面的论点中看出这一点。对这个问题的答案取决于具体情况。
我不喜欢做 TDD,我喜欢先做我的代码设计!
如果你在编写测试之前创建了类的结构,你仍然在进行 TDD。记住,TDD 是一套最佳实践,拥有自己的风格并不排除你。
单元测试并不测试真实的东西!
这是 TDD 社区试图积极改进的批评。这主要与测试替身的用法有关。
测试替身试图模仿真实对象的行为,问题在于“尝试”这个词。模仿真实对象的问题在于它依赖于开发者最好地预测真实对象的行为。这可以通过三种方式来完成:
-
阅读真实对象的文档并尝试在测试替身中编写类似的东西
-
如果有源代码,则阅读源代码并提取其精华来构建测试替身
-
进行一个概念验证样本以调用真实对象并检查其行为
这些方法需要研究和经验。有时,测试替身对象并不反映真实对象,这可能会导致错误的测试,甚至可能引发错误。让我们以第三方方法的这个例子为例:
public string LoadTextFile(string path){…}
上述方法加载一个文本文件并将其作为字符串返回。如果我们创建一个涉及此方法的测试替身,问题是如果指定的path中不存在文件会发生什么?
-
它会返回 null 吗?
-
它会返回一个空字符串吗?
-
它会抛出异常吗?如果是,异常是什么?
开发者编写测试替身时将进行必要的尽职调查以确定这些问题的答案,但他们可能会出错。前面的例子很简单,但随着方法的复杂度增加,测试替身和真实对象之间的差异也会增加。减少这种问题的方法是创建测试替身时进行适当的尽职调查。
我听说过伦敦学派 TDD 和经典学派 TDD。它们是什么?
在互联网上有一个关于我们应该使用什么以及哪个更好的争论。伦敦学派的 TDD 侧重于测试替身,更适合商业应用程序。商业应用程序是处理数据库和用户界面的应用程序。经典学派的 TDD 更适合算法类型的编码。
在这本书中,我们只讨论伦敦学派的 TDD,因为我们正在开发商业应用程序。
为什么有些开发者不喜欢单元测试和 TDD?
单元测试给产品开发增加了时间和复杂性,显然有很好的理由,尽管如此,它仍然是额外的开销。单元测试有四个主要缺点:
-
开发时间:添加单元测试会将开发时间增加数倍。那些急于尽快交付功能的开发者会发现单元测试令人难以承受。
-
修改现有功能:这需要更新单元测试。这可以通过最小化来实现,但如果没有从单元测试到 S 集成测试的重大转变,很难完全消除。这将在下一节中讨论。
-
测试替身的使用:一些开发者强烈反对使用测试替身,因为如果测试替身没有正确编码,它们往往会产生不太真实的测试。
-
单元测试具有挑战性:它需要高级编码技能和团队成员之间的协调,这需要协同作用。
单元测试不适合胆小的人。我理解这些观点,但与此同时,我知道那些寻求高质量产品的公司应该为单元测试分配更多的时间。缺点 2和缺点 3可以通过更好的编码实践来解决,这也需要更多的努力。
TDD 和敏捷 XP 之间有什么关系?
有许多敏捷工作流程的变种,最受欢迎的是敏捷 Scrum和敏捷 Kanban。然而,还有一个不那么受欢迎的,它是面向软件工程的。它被称为敏捷 XP,其中XP代表极限编程。
XP 将单元测试(尤其是 TDD)置于其实践的前沿,而其他流行的敏捷实践并没有达到这种技术细节的水平。XP 还试图解决常见的软件工程问题,如项目管理、文档和知识共享。
通过单元测试进行代码文档
XP 认为,最好的代码文档方式是将单元测试附加到代码上,而不是在其他地方编写一些会很快与代码脱节的文档。另一方面,单元测试反映了系统的当前状态,因为它们会定期检查和更新。
开发者可以通过查看单元测试来了解任何业务规则的细节,而不是阅读可能不会达到这种详细程度的文档。
一个系统没有 TDD 能生存吗?
我会把这个问题抛给开发者,问他们是否需要 TDD 带来的额外质量?你是否同意这样一个事实:随着软件的增长和团队的变动,你需要一个质量守门人?
是的,没有 TDD 和单元测试你也能生存,但质量可能会受到影响。
有一些没有单元测试的成功软件系统在企业中被实施。这是一个事实。然而,这些系统背后的团队有更高的维护成本,可能发布速度较慢,可能他们有专门的员工进行错误修复,可能遵循瀑布式 SDLC。只要组织对质量和成本满意,并将系统视为成功,这就可以了。
与 S 集成测试的 TDD
单元测试的一个批评是单元测试代码将与实现紧密耦合。更改生产代码将产生连锁反应,迫使更新、添加和删除多个单元测试。
这些是减少与单元测试耦合的方法,这些方法在第第六章中讨论,即《TDD 的 FIRSTHAND 指南》中的单一行为指南部分。然而,提供的解决方案确实减少了耦合,但并没有完全消除它们。
另一方面,集成测试依赖于被测试功能的输入和输出。如果我们对一个 API 进行集成测试,那么我们关注的是我们传递给 API 的参数以及我们得到的结果,即输入和输出。这创造了与代码的松散耦合。以下是对集成测试和单元测试如何操作的提醒:

图 5.9 – 集成测试与单元测试对比
如您所见,单元测试必须了解一些层的细节,而集成测试则关注输入和输出。这就是为什么单元测试与实现细节有更多的耦合。
集成测试有其自身的缺点,但 S 集成测试解决了我们已在第四章中讨论的一些缺点,即使用测试替身进行真实单元测试。
作为 TDD 的替代方案,S 集成测试
近年来,S 集成测试开始与单元测试竞争,作为解决单元测试这两个问题的方法:
-
从最终用户的角度进行测试(用户可能是软件客户端,不一定是人类)。这也被称为自外向内测试。
-
与代码保持松散耦合。
将 TDD 原则应用于 S 集成测试可以以下列方式工作:
-
与单元测试相同的方式应用测试优先。
-
红色、绿色、重构的方法可以与单元测试类似地工作。
-
从测试角度进行的设计与单元测试中的方式相同。
-
S 集成测试可以使用相同的模拟框架来构建伪造对象。
-
S 集成测试可以使用相同的 AAA 和方法命名约定。
主要的缺点是,虽然单元测试由于专注于小的 SUT(系统单元)而提供快速的反馈,但 S 集成测试不会给开发者提供同样快速的反馈。原因是它们需要在 S 集成测试通过之前构建整个功能的多个组件。

图 5.10 – 更快和更慢的反馈
在前面的图中,你可以看到单元测试仅在小块代码单元上操作,在构建这些单元的同时,你可以立即得到单元测试的结果。另一方面,S 集成测试的反馈将在整个功能实现后出现。
考虑这个例子。假设你正在编写一个更新用户名的功能。这个功能将包括但不限于以下代码单元:
-
检查用户名长度
-
检查用户名是否包含非法字符
-
检查用户是否有权更改用户名
-
检查用户名是否已被使用
-
如果用户提供了已使用的用户名,则提供用户的一个替代视图
-
将用户名保存到数据库中
-
向用户确认他们的用户名已更改
虽然,从理论上讲,每个代码单元都可以有多个单元测试,并且在编码每个单元时你都会得到反馈,但 S 集成测试需要等待直到功能的末尾,这样你才能得到反馈。
S 集成测试的挑战
S 集成测试仍然依赖于模拟,这些模拟是测试替身。模拟比进行模拟或存根更难构建和维护。掌握构建模拟的方法需要构建模拟和存根的经验,因为模拟通常更复杂,需要高级编码。
此外,创建模拟会有时间开销,并将延迟项目的开始,因为在你可以编写第一个 S 集成测试之前,所有相关的模拟都应该准备好。例如,如果你的 S 集成测试涉及访问文档数据库和云存储,你可能需要首先为这些组件创建模拟,然后再进行任何有用的 S 集成测试。
使用 S 集成测试进行 TDD 的实践比使用单元测试的实践需要更多的经验。然而,好消息是,遵循这本书的内容将帮助你进步,所以当时机到来,你和你的团队决定专注于 S 集成测试时,你将已经获得了必要的经验来这样做。
摘要
我们已经了解了 TDD 的基本原理,因此,我相信在这个阶段,你能够自信地向同事描述这个过程。然而,这一章节是学习 TDD 的开始,因为随着你的学习,这本书将继续为你增加知识。
我抑制了自己写下更多高级示例的冲动,并在这里停下来,以便更平滑地引入。我希望我已经以清晰的方式解释了这个概念,并鼓励你继续阅读这本书,因为接下来的章节将包含更多实用的示例,这些示例将帮助你将 TDD 应用到自己的项目中。
在下一章中,我们将讨论 TDD 指南以及我所说的 FIRSTHAND 指南。你将了解为什么先测试很重要以及它为你提供了什么价值。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下链接:
-
马丁·福勒关于测试驱动开发:
martinfowler.com/bliki/TestDrivenDevelopment.xhtml -
经典 TDD 或“伦敦学派”?:
codemanship.co.uk/parlezuml/blog/?postid=987
第六章:TDD 的 FIRSTHAND 指导原则
TDD 不仅仅是先测试单元测试或红-绿-重构的方法。TDD 包括最佳实践和指导原则,这些原则指导你如何使用单元测试进行工作。
我想根据我的经验,列出最有用的单元测试和 TDD 指导原则的难忘列表。因此,以下是我简称为FIRSTHAND的九项经过验证的最佳实践。FIRSTHAND 代表:
-
第一
-
意图
-
可读性
-
单一行为
-
彻底性
-
高性能
-
自动化
-
无依赖性
-
确定性
在本章中,我们将逐一介绍这九项指导原则,并通过相关的实际案例来支持它们。到本章结束时,你应该对 TDD 的生态系统及其指导原则有一个公正的理解。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch06
第一条指导原则
单元测试应该首先编写。一开始这可能看起来很奇怪或不直观,但这个选择有合理的理由。
晚了就是永远
你听过多少次“我们稍后再测试”的说法?我从未见过一个团队完成项目并发布到生产环境中后,再分配时间来对代码进行单元测试。
此外,在最后添加单元测试将需要代码重构,这可能会破坏产品,并且很难向非技术人员解释一个正在运行的系统被破坏是因为团队正在添加单元测试。实际上,说“我们因为添加单元测试而破坏了生产”听起来很讽刺。是的,你可以在其他类型的测试(如 S 集成测试和验收测试)的覆盖下重构一个正在运行的系统,但很难想象一个之前没有时间进行单元测试的团队能有时间构建其他完全覆盖系统的测试。
先测试确保单元测试和功能是同步开发的,测试不会被遗漏。
准备好依赖注入
当你习惯了现代软件开发风格,即先创建服务然后注入它,你就不会回头了。软件框架已经发展到使 DI 成为一等公民。以下是一些例子:
-
Angular Web 框架:你只能通过依赖注入(DI)在 Angular 中获取服务,而其他方式则难以实现。
-
Microsoft MAUI:MAUI 是 Xamarin.Forms 的改进版,其中一个主要变化是将 DI 作为一等公民。
-
.NET Core 控制台:传统的.NET Framework 控制台应用程序不支持 DI,但现在在 Core 中原生支持,这为在控制台应用程序之上构建支持 DI 的其他库铺平了道路,例如 ASP.NET Core。
-
ASP.NET Core:经典 ASP.NET 和 ASP.NET Core 之间的一大区别是将依赖注入(DI)作为一等公民。
这些都是强烈的信号,告诉你无法逃避使用依赖注入(DI)。在软件实现后添加 DI 将需要重大的重构和重新思考一切。
从单元测试开始将强制从第一刻起进行依赖注入(DI)。
从客户端的角度进行设计
TDD 鼓励你从客户端(调用者)的需求出发思考,而不是陷入实现细节。你被鼓励在考虑实现细节之前,先考虑面向对象设计,如类名、抽象、决定方法签名和返回类型。
如果你有一个被其他系统或库使用的公共接口(类和方法的组合),那么与改变你代码的实现相比,改变这个接口会更困难,因为它可能已经被其他系统使用。
TDD 强制从客户端的角度设计代码。
促进行为测试
单元测试应该关注系统在测试(SUT)做什么,而不是它是如何做的。在单元测试中,你想要推送一定的输入,检查依赖项是如何受影响的,并检查输出。你不应该检查的是 SUT 内部是如何完成所有这些工作的。
如果你决定检查 SUT 的内部,你的单元测试将与实现细节紧密耦合。这意味着方法中的任何更改都会对相关的单元测试产生连锁反应。这将导致更多的单元测试和脆弱的测试。这里值得迭代的是,测试既是资产也是负债。拥有更多的测试,这些测试往往是多余的,意味着更多的维护。
测试先行会让你自然地考虑输入、输出和副作用,而不是 SUT 实现的细节。在实现之后进行测试会导致我所说的欺骗,即开发者查看 SUT 实现代码并相应地编写测试。这可能会无意中导致测试实现细节。
TDD 提倡单元测试的口号:测试行为,而不是实现细节。
消除误报
误报是指测试因为错误的原因通过。这种情况并不常见,但一旦发生,就很难捕捉。
TDD 使用红-绿方法来消除误报。
消除投机性代码
我们都曾写过代码,心想,“也许我们将来会用得上”,或者“让我把它留在这里,因为其他” 同事 可能会觉得它有用。这种方法的缺点是,这段代码可能永远不会被使用,但需要维护。更糟糕的是,如果将来被使用,它可能会给人一种已经测试过的错觉,而实际上它只是在等待未来的开发者来测试它。
TDD 通过编写仅用于生产的代码来消除投机性代码。
意图指南
当你的系统增长时,它将驱动更多的单元测试,这些测试将自然覆盖系统行为和文档。随着测试数量的增加,责任也更大:可读性和维护性。
测试的数量将增长到一定程度,以至于团队将无法记住编写它们的原因。你将看到失败的测试,并挠头寻找关于测试意图的线索。
你的单元测试应该以最短的时间和精力来理解;否则,它们将更多地成为负担而不是资产。敏捷软件开发团队应该提前准备好这样的测试失败场景。意图可以通过清晰的方法签名和良好的方法结构来体现。
从方法签名开始,这里有两个流行的约定,应该可以阐明单元测试的意图。
方法 _ 条件 _ 期望
我一直在本书中用这个约定命名单元测试方法:Method_``Condition_Expectation。这是一个简洁的命名约定,不允许创新的方法名,在我看来,它为其他任务保留了创新。这导致了一个更无聊但标准的方法名。以下是一个例子:
LoginUser_UsernameDoesntExist_ThrowsInvalidOperationException
这仍然不是一个精确的约定,但已经足够好了。例如,一些开发者可能会反对使用Throws这个词,因为它很明显,因为使用了Exception这个词。
重要的是在短时间内,从前面方法名中看到的三部分,确定这个测试的意图。
注意
我看到过一些团队省略了Method部分,只使用Condition_Expectation,特别是如果整个单元测试类只针对一个方法。
方法 _Should_When
另一个流行的约定,允许使用更自然语言的是使用Method_Should_When。这个约定更接近流畅编码,代码的流动就像一个英文句子。以下是一个例子:
LoginUser_Should_Throw_InvalidOperationException_When_UsernameDoesntExist
有时,这个约定的倡导者喜欢也使用流畅断言来进行断言:
// Assert
IsSaved.Should().BeTrue();
如你所注意到的,前面的代码与书中使用的 xUnit 风格不同:
// Assert
Assert(true, IsSaved);
如果你感兴趣于使用流畅断言,你可以看看一个名为Shouldly的.NET 库(github.com/shouldly/shouldly)。在附录 1中,常用单元测试库,我们讨论了一个类似的库,名为流畅断言。
下一步明确意图是明确方法体。
单元测试结构
结构化单元测试主体的主导方法是流行的安排-行动-断言(AAA)。让我们更深入地了解每个部分应该做什么的意图。
Arrange
Arrange旨在实现两个目标:
-
初始化变量
-
创建 SUT 状态
Arrange部分可能与单元测试类的构造函数共享,因为构造函数可能在进行一些准备以减少每个单元测试中的Arrange代码。换句话说,可能有一些安排发生在这个部分之外。我们将在本书的第二部分,使用 TDD 构建应用程序中看到这个例子。
初始化对象和初始化期望发生在Arrange部分,这是显而易见的。不那么明显的是,这个部分设置了一个状态。以下是一个澄清状态含义的例子:
LoginUser_UsernameDoesntExist_ThrowsInvalidOperationException
Arrange将创建一个用户在系统中不存在的状态,在大多数情况下,这段代码将与Method_Condition_Expectation中的Condition紧密相关。在这种情况下,安排应该与UsernameDoesntExist相关联。
行动
Act主要是一行代码,调用方法名第一部分指定的相同方法。在先前的例子中,我期望act看起来像这样:
// Act
var exception = Record.Exception(() => sut.LoginUser(…));
此方法重申了方法签名中指定的意图,并提供了干净的代码。
断言
Assert正在断言方法签名最后部分的条件:Expectation。
签名约定和主体结构共同作用,提供清晰的意图。请记住,无论你使用了什么约定,关键是保持一致性和清晰性。
清晰的意图单元测试将促进更易于维护和更准确的文档。
可读性指南
这个方法可读吗?你需要运行它并开始调试来理解它做什么吗?Arrange部分让你的眼睛感到不适吗?这可能会违反可读性原则。
建立意图指南是极好的,但这还不够。与你的生产代码相比,你的单元测试中至少会有 10 倍多的代码行。所有这些都需要维护并随着你的系统一起增长。
为了提高可读性而对单元测试进行整理遵循与生产代码相同的实践。然而,在单元测试中,有一些场景更为突出,我们将在这里解决这些问题。
SUT 构造函数初始化
初始化你的 SUT 将需要你准备所有依赖项并将它们传递给 SUT,类似于这样:
// Arrange
const double NEXT_T = 3.3;
const double DAY5_T = 7.7;
var today = new DateTime(2022, 1, 1);
var realWeatherTemps = new[]
{2, NEXT_T, 4, 5.5, 6, DAY5_T, 8};
var loggerMock =
Substitute.For<ILogger<WeatherController>>();
var nowWrapperMock = Substitute.For<INowWrapper>();
var randomWrapperMock = Substitute.For<IRandomWrapper>();
var clientMock = Substitute.For<IClient>();
clientMock.OneCallAsync(Arg.Any<decimal>(), Arg.Any<decimal>(),
Arg.Any<IEnumerable<Excludes>>(), Arg.Any<Units>())
.Returns(x =>
{
const int DAYS = 7;
OneCallResponse res = new OneCallResponse();
res.Daily = new Daily[DAYS];
for (int i = 0; i < DAYS; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = today.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day =
realWeatherTemps.ElementAt(i);
}
return Task.FromResult(res);
});
var controller = new WeatherController(loggerMock,
clientMock, nowWrapperMock, randomWrapperMock);
…
现在我们已经完成了所有的编码准备工作,我们可以初始化 SUT(在本例中为控制器)并传递给它正确的参数。这在大多数与 SUT 相同的测试中都会重复,这将使它成为一个阅读噩梦。这段代码可以轻松地移动到单元测试类的构造函数中,变成这样:
private const double NEXT_T = 3.3;
private const double DAY5_T = 7.7;
private readonly DateTime _today = new(2022, 1, 1);
private readonly double[] _realWeatherTemps = new[]
{ 2, NEXT_T, 4, 5.5, 6, DAY5_T, 8 };
private readonly ILogger<WeatherForecastController> _loggerMock
= Substitute.For<ILogger<WeatherForecastController>>();
private readonly INowWrapper _nowWrapperMock =
Substitute.For<INowWrapper>();
private readonly IRandomWrapper _randomWrapperMock =
Substitute.For<IRandomWrapper>();
private readonly IClient _clientMock =
Substitute.For<IClient>();
private readonly WeatherForecastController _sut;
public WeatherTests()
{
_sut = new WeatherForecastController(_loggerMock,
_clientMock,_nowWrapperMock, _randomWrapperMock);
}
之前代码的美丽之处在于它对所有同一类的单元测试都是可重用的。你的单元测试中的代码变成了这样:
// Arrange
_clientMock.OneCallAsync(Arg.Any<decimal>(),
Arg.Any<decimal>(),
Arg.Any<IEnumerable<Excludes>>(), Arg.Any<Units>())
.Returns(x =>
{
const int DAYS = 7;
OneCallResponse res = new OneCallResponse();
res.Daily = new Daily[DAYS];
for (int i = 0; i < DAYS; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = _today.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day =
_realWeatherTemps.ElementAt(i);
}
return Task.FromResult(res);
});
…
这个类中单元测试的Arrange大小已经降低。记住,你在这里看到的是一个单元测试方法,但你可能有多个针对同一 SUT 的单元测试。
你可能会争辩说,虽然我们在Arrange中清除了一些重复的代码,但它仍然很忙。让我们用构建器设计模式来解救。
构建器模式
对于同一 SUT,每个单元测试的安排与其他单元测试略有不同。当创建具有许多可能配置选项的对象时,构建器设计模式很有用,这在这种情况下很有帮助。
注意
这与四人帮(GoF)构建器设计模式不同。
对于前面的例子,构建器类看起来像这样:
public class OneCallResponseBuilder
{
private int _days = 7;
private DateTime _today = new (2022, 1, 1);
private double[] _temps = {2, 3.3, 4, 5.5, 6, 7.7, 8};
public OneCallResponseBuilder SetDays(int days)
{
_days = days;
return this;
}
public OneCallResponseBuilder SetToday(DateTime today)
{
_today = today;
return this;
}
public OneCallResponseBuilder SetTemps(double[] temps)
{
_temps = temps;
return this;
}
public OneCallResponse Build()
{
var res = new OneCallResponse();
res.Daily = new Daily[_days];
for (int i = 0; i < _days; i++)
{
res.Daily[i] = new Daily();
res.Daily[i].Dt = _today.AddDays(i);
res.Daily[i].Temp = new Temp();
res.Daily[i].Temp.Day = _temps.ElementAt(i);
}
return res;
}
}
在这个类中值得注意的是:
-
每个方法都返回类实例。这有助于像这样链式调用方法:
OneCallResponse res = new OneCallResponseBuilder() .SetDays(7) .SetTemps(new []{ 0, 3.3, 0, 0, 0, 0, 0 }) .Build(); -
Build()方法将所有配置组合在一起,以返回一个可用的对象。
之前单元测试的重构Arrange看起来像这样:
// Arrange
OneCallResponse res = new OneCallResponseBuilder()
.SetTemps(new []{ 0, 3.3, 0, 0, 0, 0, 0 })
.Build();
_clientMock.OneCallAsync(Arg.Any<decimal>(),
Arg.Any<decimal>(), Arg.Any<IEnumerable<Excludes>>(),
Arg.Any<Units>())
.Returns(res);
上一段代码利用了我们之前创建的构建器类。你可以清楚地看到代码将第二天温度设置为 3.3 度。
使用 SUT 构造函数初始化和构建器模式只是使你的单元测试可读性的几个例子。
你可以在配套源代码中的WeatherForecastTestsReadable.cs找到重构后的类,以及在WeatherForecastTestsLessReadable.cs中找到原始类。
可读性促进了你的单元测试代码库的健康成长。从第一天开始就要保持其可控性。
单一行为指南
每个单元测试都应该测试一个且仅有一个行为。在整个书中,这个概念通过以下方式自然地得到了强化:
-
单元测试方法签名的命名,它反映了一个条件和一个期望
-
一个单一的 AAA 结构强制执行单一的
Act
在进一步挖掘之前,我想定义一下单词行为。
什么是行为?
行为的定义在行业中有所不同,因此对于本书的上下文来说,设置一个准确的行为定义很重要。每个 SUT 都应该做某件事。SUT 通过以下方式来完成这件事:
-
与依赖项通信:通信可以通过在依赖项上调用方法或设置字段或属性来完成——这被称为外部行为。
-
Exception或返回值(如果方法不是void或Task方法)——这通常被称为外部行为。 -
整体规划:在接收输入、准备输出(返回值)或准备与依赖项通信之前执行各种命令——这被称为 SUT 的内部或内部行为。
外部行为在系统中传播,因为它正在接触其他依赖项,而内部行为被封装在 SUT 中,不会展示给外界。
当我们单独使用“行为”这个词时,我们指的是“外部行为”,因此单一行为指南指的是单一外部行为。通常,定义似乎比实际要复杂,所以让我们用一个例子来加强这个定义。
行为的例子
让我们从天气预报应用(WFA)中取出这段代码,该应用在第二章,通过例子理解依赖注入中介绍过:
public async Task<IEnumerable<WeatherForecast>> GetReal()
{
const decimal GREENWICH_LAT = 51.4810m;
const decimal GREENWICH_LON = 0.0052m;
OneCallResponse res = await _client.OneCallAsync
(GREENWICH_LAT, GREENWICH_LON, new[]{Excludes.Current,
Excludes.Minutely, Excludes.Hourly, Excludes.Alerts},
Units.Metric);
WeatherForecast[] wfs = new
WeatherForecast[FORECAST_DAYS];
for (int i = 0; i < wfs.Length; i++)
{
var wf = wfs[i] = new WeatherForecast();
wf.Date = res.Daily[i + 1].Dt;
double forecastedTemp = res.Daily[i + 1].Temp.Day;
wf.TemperatureC = (int)Math.Round(forecastedTemp);
wf.Summary = MapFeelToTemp(wf.TemperatureC);
}
return wfs;
}
private string MapFeelToTemp(int temperatureC)
{
…
}
在前面的代码中,所有外部行为都在 _client.OneCallAsync 调用和 return 语句中。其余的代码都是内部的。你可以将内部代码的作用视为准备触发 _client 依赖项并返回一个值。
一旦触发这两个外部行为,内部行为就不再相关;它们被执行并遗忘,而外部行为则传播到其他服务。
仅测试外部行为
如果内部代码的作用仅仅是准备外部行为,那么测试外部行为将涵盖测试整个代码。你可以将其视为测试内部代码是测试外部行为的副产品。
这里有一些单元测试行为的例子(外部行为)。这些例子在源代码的第三章,开始单元测试中得到了完全实现(WF代表天气预报):
GetReal_NotInterestedInTodayWeather_WFStartsFromNextDay
GetReal_5DaysForecastStartingNextDay_
WF5ThDayIsRealWeather6ThDay
GetReal_ForecastingFor5DaysOnly_WFHas5Days
GetReal_WFDoesntConsiderDecimal_RealWeatherTempRoundedProperly
GetReal_TodayWeatherAnd6DaysForecastReceived_
RealDateMatchesLastDay
GetReal_TodayWeatherAnd6DaysForecastReceived_
RealDateMatchesNextDay
GetReal_RequestsToOpenWeather_MetricUnitIsUsed
GetReal_Summary_MatchesTemp(string summary, double temp)
在执行仅针对外部行为的测试之后,代码覆盖率工具将显示所有在先前的例子中展示的代码都被我们的测试覆盖了。这包括GetReal()公共方法中的代码和MapFeelToTemp()私有方法中的代码。让我们看看代码覆盖率的一个例子:

图 5.1 – 由行为测试覆盖的行
在前面的图中,我使用了一个名为Fine Code Coverage(FCC)的 VS 插件来显示所选测试覆盖的行。它显示所有 SUT 中的代码行都被这些测试覆盖了。我们将在彻底性指南部分更详细地讨论覆盖率和这个插件工具。
为什么不测试内部代码?
单元测试中的一个常见错误是当开发者试图测试 SUT 的内部代码。以下是测试内部代码的一些问题:
-
当测试外部行为时,SUT 已经得到覆盖,因此不需要增加单元测试的数量,这将增加维护(责任)。
-
测试内部代码将在 SUT 和测试之间创建紧密耦合,这将创建脆弱的测试,这些测试将不得不经常更改。
-
内部代码通常隐藏在非公共方法后面;要测试它们,代码需要更改以公开,这违反了面向对象封装的原则。
每个测试一个行为
现在行为定义已经明确,我们可以澄清测试单个行为意味着什么。测试单个行为与意图指南并行工作。如果我们针对单个行为,我们的测试将促进更好的意图和可读性,当它失败时,我们应迅速找出原因。
单个行为测试由一个 SUT(系统单元)、一个条件、一个期望和最小断言组成。前述列表(章节:仅测试外部行为)中的单元测试签名是针对单个行为的例子。此外,本书中的所有示例都遵循相同的指南。
单元测试方法应该测试单个行为,而永远不应该测试内部结构。
完整性指南
当进行单元测试时,以下是一些自然出现的问题:
-
需要进行多少测试才算足够?
-
我们是否有测试覆盖率指标?
-
我们是否应该测试第三方组件?
-
我们应该对哪些系统组件进行单元测试,以及我们应该留下哪些?
完整性指南试图为这些问题的答案设定标准。
依赖项测试的单元测试
当你遇到依赖项时,无论这个依赖项是否是系统的一部分还是第三方依赖项,你都会为它创建一个测试替身,并将其隔离以测试你的 SUT。
在单元测试中,你不会直接调用第三方依赖项;否则,你的代码将变成集成测试,这样你就失去了单元测试的所有好处。例如,在单元测试中,你不会调用以下内容:
_someZipLibrary.Zip(fileSource, fileDestination);
为了测试这一点,你为.zip库创建一个测试替身,以避免调用真实的东西。
这是一个单元测试不覆盖且不应该覆盖的领域,这导致我们面临覆盖率问题,因为代码的某些部分无法进行单元测试。
为了测试与依赖项的交互并解决之前无法对某些代码进行单元测试的问题,可以采用其他类型的测试,例如 S 集成测试、集成测试和验收测试。
我们开始讨论覆盖率;现在我们可以更深入地探讨这个话题。
代码覆盖率是什么?
理解完整性的第一步是理解代码覆盖率。代码覆盖率是您的测试(单元、S 集成、集成等)从总系统行中执行的系统代码行的百分比。假设我们有一个返回整数是否为偶数的方法:
public bool IsEven(int number)
{
if(number % 2 == 0) return true;
else return false;
}
让我们编写一个单元测试来测试一个数字是否为偶数:
public void IsEven_EvenNumber_ReturnsTrue() {…}
这个单元测试会覆盖if行,但不会执行else行。这构成了 50%的代码覆盖率。显然,如果有另一个测试来测试奇数,就会达到 100%的覆盖率。
重要的是要认识到代码覆盖率并不一定是单元测试覆盖率,它可能是单元测试、集成测试和集成测试的组合。然而,在所有测试中,单元测试通常覆盖了代码的最大部分,因为它们比其他测试更容易编写。此外,在使用 TDD 风格时,一旦实现了功能,您就会获得高覆盖率,因为所有相关的单元测试已经提供。
覆盖率测量工具
要测量您的代码被测试覆盖了多少,通常您会在 持续集成(CI)(将在本书的 第十一章,使用 GitHub Actions 实现持续集成 中讨论)和/或开发机器上测量。有大量的商业选项用于运行测试覆盖率,以及有限的免费选项。以下是一些示例:
-
NCover – 商业版
-
dotCover – 商业版
-
NCrunch – 商业版
-
VS Enterprise 代码覆盖率 – 商业版
-
SonarQube – 商业版和社区版
-
AltCover – 免费版
-
FCC – VS 的免费插件
如果您想了解代码覆盖率是如何工作的,可以按照以下步骤安装 FCC:
-
从菜单中选择 扩展 | 管理扩展,将打开 管理扩展 对话框。
-
选择 在线 | Visual Studio Marketplace。
-
搜索
细粒度代码覆盖率并选择 下载。

图 5.2 – 安装 FCC 对话框
- 重新启动 VS。
安装后,您可以从 第三章,开始单元测试 中打开项目并执行所有单元测试(测试 | 运行所有测试)。在执行测试几秒钟后,此工具将自动触发。要查看结果,将出现如下面板,显示在 VS 底部:

图 5.3 – FCC 分析结果
如果您看不到此面板,您可能需要从菜单中转到 查看 | 其他窗口 | 细粒度代码覆盖率。
我只对生产代码覆盖率感兴趣,在这个例子中是 Uqs.Weather。我可以看到我的总覆盖率是 55.2%。
我们将在接下来的几节中进一步讨论之前的代码覆盖率结果。
单元测试覆盖率范围
单元测试非常适合测试以下内容:
-
业务逻辑
-
验证逻辑
-
算法
-
组件之间的交互(不要与组件之间的集成混淆)
另一方面,单元测试并不适用于以下方面的测试:
-
组件之间的集成 – 从组件 A 通过到组件 B 的调用,例如从数据库获取信息并分析数据
-
服务启动组件,例如
Program.cs -
直接调用依赖项(如前所述的依赖项测试)
-
封装真实组件的封装器 – 例如以下示例中的
RandomWrapper:public interface IRandomWrapper { int Next(int minValue, int maxValue); } public class RandomWrapper : IRandomWrapper { private readonly Random _random = Random.Shared; public int Next(int minValue, int maxValue) { return _random.Next(minValue, maxValue); } }
单元测试不应该对这个类中的任何内容感兴趣进行测试,因为这个类直接封装了真实组件。
现在,如果我们回到代码覆盖率的成果,很明显为什么Program.cs、NowWrapper.cs和RandomWrapper.cs的覆盖率是 0%。最好这些文件中的代码不要通过单元测试进行测试,而我们还没有这样做。
WeatherForecastController的覆盖率是 80%。你可以打开文件查看 FCC 的突出显示。

图 5.4 – FCC 的突出显示
看起来GetRandom中的每一行都没有被测试,因为它们都是红色的。我可能在我的任何测试中都没有针对这个方法。显然,如果没有使用 TDD,一个完整的方法没有被测试是不会发生的;然而,这是一个随 VS 示例代码一起提供的 ASP.NET 项目的示例方法。
现在我们已经了解了代码覆盖率是什么以及什么可以被覆盖,我们可以描述一下在测试中要全面是什么意思。
要全面
显然,最佳的覆盖率水平是 100%;或者至少这应该是目标,但它并不容易实现,有时,达到它并不值得付出成本和努力。
首先,正如讨论的那样,单元测试的目的不是提供 100%的覆盖率,因此它们需要由其他测试类别的测试来补充。如果我们想通过单元测试实现 100%的覆盖率,我们将不得不强行将单元测试用于不适合单元测试的事情。
要全面就是结合单元测试、S 集成测试、集成测试和用户验收测试。
成本、时间和质量三角
这可能不是你第一次听到这个概念。这是一个项目管理概念,并不特定于软件工程。以下是流行的三角:

图 5.5 – 成本、时间和质量三角
质量在我们的三角中代表我们希望多么全面。它显然是时间和成本的函数。
通常来说,测试是一个耗时的过程,单元测试所需的时间与编写生产代码所需的时间相当,甚至更多。单元测试是由编写生产代码的同一开发者编写的,它们通常不是并行完成的事情;它们是按顺序完成的。TDD 的红色-绿色-重构流程是由编写代码的同一个人或团队完成的,而不是由一个单独的测试人员在并行中进行。
多么全面?
目标超过 80%的覆盖率是好的。95%的覆盖率是在合理努力下可以达到的最大值。这仅适用于单元测试与 S 集成测试或单元测试与集成测试的组合。验收测试通常不计入覆盖率。
更加彻底并追求更高的覆盖率是更多时间和成本的问题。因此,覆盖率多少的问题是一个项目管理以及你团队的问题。
追求彻底是使用单元测试与 S 集成测试或单元测试与集成测试的组合来达到高覆盖率。这是在考虑时间、成本和质量三角关系。
高性能指南
在今天的硬件上,你的单元测试运行时间不应超过 5 秒,理想情况下,在测试加载后不超过几秒钟。但为什么会有这么多麻烦?我们难道不能让他们以所需的时间运行,而不必为此烦恼吗?
首先,你的单元测试将在一天中多次运行。TDD 是关于每次更改时运行你的一部分单元测试或所有单元测试;因此,你不想浪费时间等待,从而失去本可以更有效地利用的时间。
第二,你的单元测试需要为你的 CI 管道提供快速的反馈。你希望你的源代码控制分支始终保持绿色,这样其他开发者就可以在任何时候拉取绿色代码,当然,这也意味着它已经准备好可以部署到生产环境中。这对大型团队来说尤为重要。
那么,你如何保持你的单元测试尽可能快地执行?我们将在下一节尝试回答这个问题。
集成作为伪装的单位
我在许多项目中都见过这种情况,开发者只是因为他们的集成测试是由 NUnit 或 xUnit 执行的,就将它们称为单元测试。
集成测试由于进行 IO 操作(如写入或读取磁盘、访问数据库和通过网络)而本身较慢。这些操作消耗时间,导致测试运行需要几分钟或更长时间。
单元测试使用测试替身,并依赖于内存和 CPU 来运行。在项目加载后运行 10K 个单元测试应该只需几秒钟,所以请确保你并没有执行集成测试。
CPU 和内存密集型单元测试
你可能在进行依赖数学库的单元测试,而这些库没有被当作测试替身,或者你可能有一些涉及复杂逻辑的代码。
你可以有多个可以在不同时间执行的单元测试类别。xUnit 将此功能称为特性。你可以有一个特性来指示慢速测试。
你可以在 TDD 期间执行更快的测试,并在将代码推送到 代码库(源代码控制) 之前执行所有测试。
测试过多
假设你已经成为 TDD 大师并达到了 10K 个测试,你开始遇到运行时间超过 5 秒的测试。
我会直接问:
-
你的项目是不是那种臃肿的单一类型?
-
你的问题是什么:慢速测试还是应该拆分为微服务的项目?
对于这个问题的一个临时解决方案是拥有多个 VS 解决方案,并找出如何将相关项目划分到不同的解决方案中。
过多的测试是架构薄弱的迹象。可以考虑采用替代架构,如微服务,这可能自动解决单元测试问题。
高性能单元测试是你从第一天开始就追求的,并在构建项目时保持检查的。
自动化指南
当我们说自动化时,我们指的是持续集成(CI)。这本书中有一章专门介绍 CI,即第十一章第十一章,使用 GitHub Actions 实现持续集成,所以我们在这里不会深入细节。
这个指南是关于意识到单元测试将在除本地开发机器以外的其他平台上运行。那么,你如何确保你的单元测试为自动化做好准备?
从第一天开始进行 CI 自动化
敏捷团队将第一个冲刺用于设置环境,包括 CI 管道。这通常被称为冲刺或零迭代。如果从第一天开始设置 CI 以监听源代码控制,那么它被省略或引入了不兼容 CI 的测试的可能性就会更小。
从项目开始就实施 CI。
平台无关性
.NET 是多平台的,现在的趋势是使用 Linux 服务器来运行 CI 管道。此外,开发者的开发机器可以是 Windows、macOS 或 Linux。
确保你的代码不依赖于任何特定于 OS 的功能,如果不是真的需要,这样你就可以自由选择用于 CI 管道的操作系统。
CI 上的高性能
现在,CI 管道通常是来自 Azure DevOps 或 GitHub Actions 等 CI 提供商的租赁服务。这些租赁服务资源有限(CPU 和内存),并在多个项目之间共享。
如果你有不错的开发机器,并且没有为 CI 资源支付巨额费用,那么可以公平地说,测试的运行时间将是本地机器的两倍,甚至更多。
这重申了高性能测试是必要的这一事实。
确保你在开发机器上编写的任何测试都准备好在 CI 上运行。
无依赖性指南
首先,我想把这个从指南提升为一个原则。这个原则确保单元测试不会永久更改状态;换句话说,执行单元测试不应持久化数据。基于这个原则,我们有以下规则:
-
测试 A 不应该影响测试 B。
-
测试 A 是否在测试 B 之前运行无关紧要。
-
我们是否并行运行测试 A 和测试 B 无关紧要。
如果你仔细想想,单元测试就是在创建测试替身并在内存中执行其操作,一旦执行完成,所有的更改都会丢失,除了测试报告。因为所有这些依赖项都作为测试替身提供的,所以数据库、文件或任何地方都没有保存任何内容。
有了这个原则,也确保测试运行器,如测试资源管理器,可以并行运行测试,并在需要时使用多线程。
确保这一原则是单元测试框架和开发者之间的共同责任。
单元测试框架的责任
单元测试框架应确保在执行每个单元测试方法后重新初始化单元测试类。单元测试类不应以与普通类维护状态相同的方式维护状态。让我用一个例子来演示:
public class SampleTests
{
private int _instanceField = 0;
[Fact]
public void UnitTest1()
{
_instanceField += 1;
Assert.Equal(1, _instanceField);
}
[Fact]
public void UnitTest2()
{
_instanceField += 5;
Assert.Equal(5, _instanceField);
}
}
当单元测试框架运行UnitTest1和UnitTest2时,它不应该从同一个对象中运行它们;它为每个方法运行创建一个新的对象。这意味着它们不共享实例字段。否则,如果UnitTest1在UnitTest2之前运行,那么我们应该断言6。
重要提示
在方法中修改实例字段,然后在同一方法中对其断言,这不是好的单元测试实践,但为了演示目的,已经这样做了。
xUnit 遵循确保状态不共享的原则。然而,可以通过使用静态成员来指示它做相反的事情。
开发者的责任
作为开发者,你知道你不应该在单元测试期间持久化数据。否则,这从定义上讲,就不再是单元测试。
如果开发者使用测试替身来替换数据库、网络和其他依赖项,他们通过这种方式遵守了这一规则。然而,如果开发者决定使用静态字段,问题就出现了。静态字段将在独立方法调用之间保留状态。让我们来看一个例子:
public class SampleTests
{
private static int _staticField = 0;
[Fact]
public void UnitTest1()
{
_staticField += 1;
Assert.Equal(1, _staticField);
}
[Fact]
public void UnitTest2()
{
_staticField += 5;
Assert.Equal(6, _staticField);
}
}
之前的代码违反了这一规则,因为在运行UnitTest1之前运行UnitTest2将导致测试失败。实际上,根据它们在类中的顺序,无法保证第二个方法会在第一个方法之后运行。
有一些情况下使用静态字段可以提升性能。比如说,我们有一个需要用于所有测试的内存中只读数据库。假设初始化这个数据库需要很长时间,比如 100 毫秒:
private readonly static InMemoryTerritoriesDB =
GetTerritories();
字段是只读的,类的内容也是只读的,例如readonly record。尽管如此,对于这种情况,我争论如果你需要所有领土,或者你能创建一个适合单元测试的简化版本吗?这可能加快加载速度,并可以消除对静态字段的需求。
作为开发者,你应该小心不要在多个单元测试之间创建状态。如果你是单元测试领域的初学者,这很容易忽略。
没有相互依赖导致代码更容易维护,并减少了单元测试中的错误。
确定性指南
单元测试应该具有确定性行为,并应导致相同的结果。这应该不受以下因素的影响:
-
时间:这包括时区变化和在不同时间进行测试。
-
环境:例如本地机器或 CI/CD 服务器。
让我们讨论一些可能导致非确定性单元测试的情况。
非确定性情况
有一些情况可能导致非确定性单元测试。以下是一些例子:
-
拥有相互依赖的单元测试,例如写入静态字段的测试。
-
在开发机器上将具有绝对路径的文件作为文件位置加载的文件将不会与自动化机器上的匹配。
-
访问需要更高权限的资源。例如,当以管理员身份运行 VS 时,这可能有效,但可能从 CI 管道运行时失败。
-
使用随机化方法而不将其视为依赖项。
-
依赖于系统时间而不将其视为依赖项。
接下来,我们将看到一个不依赖于变化时间的案例,以使我们的单元测试具有确定性。
冻结时间的示例
如果你的测试依赖于时间,你应该使用测试替身来冻结时间,以确保测试具有确定性。以下是一个示例:
public interface INowWrapper
{
DateTime Now { get; }
}
public class NowWrapper : INowWrapper
{
public DateTime Now => DateTime.Now;
}
这是一个包装器,允许将当前时间作为依赖项注入。要在 Program.cs 中注册包装器:
builder.Services.AddSingleton<INowWrapper>(_ => new
NowWrapper());
您的服务可以看起来像这样:
private readonly INowWrapper _nowWrapper;
public MyService(INowWrapper nowWrapper)
{
_nowWrapper = nowWrapper;
}
public DateTime GetTomorrow() =>
_nowWrapper.Now.AddDays(1).Date;
上一段代码中的计算仅用于演示目的,并未考虑夏令时。要从单元测试中注入当前时间:
public void GetTomorrow_NormalDay_TomorrowIsRight()
{
// Arrange
var today = new DateTime(2022, 1, 1);
var expected = new DateTime(2022, 1, 2);
var nowWrapper = Substitute.For<INowWrapper>();
nowWrapper.Now.Returns(today);
var myService = new MyService(nowWrapper);
// Act
var actual = myService.GetTomorrow();
// Assert
Assert.Equal(expected, actual);
}
之前的单元测试已将当前时间冻结到指定的值。这使得代码独立于操作系统的时钟,从而使其具有确定性。
运行单元测试应始终产生相同的结果,无论时间或环境因素如何。
摘要
FIRSTHAND 在行业中积累了宝贵的指南和最佳实践。我相信这一章补充了前几章的学习,帮助你理解 TDD 和其生态系统。我也希望它使这些指南记忆深刻,因为 TDD 经常出现在开发者讨论中,并且它肯定是一个面试话题。
本章标志着本节的结束,我们在这里探讨了依赖注入、单元测试和 TDD。本节只是对 TDD 的一个介绍,包含了一些零散的小型和中等示例。如果你已经到达这个位置,那么恭喜你,你已经掌握了 TDD 的基础知识。
下一节将把所有基础知识应用到更贴近现实场景中。为了确保你为这个应用程序做好准备,并模仿使用 TDD 的现实应用程序,我们的下一章将关于 领域驱动设计 (DDD),因为你将在后面的章节中使用 DDD 概念。
第二部分:使用 TDD 构建应用程序
TDD 通常与 领域驱动设计 (DDD) 架构结合使用。在本部分中,我们将结合我们在 第一部分 中学到的知识,使用 TDD 和 DDD 构建一个完整的应用程序;在一个实例中,我们将使用关系型数据库 (第九章),在另一个实例中使用文档数据库 (第十章) 来展示这将对我们的单元测试实现产生什么影响。
到本部分结束时,你应该能够从头开始使用 TDD 和 DDD 构建应用程序。本部分包括以下章节:
-
第七章,对领域驱动设计的实用观点
-
第八章, 设计预约应用
-
第九章, 使用 Entity Framework 和关系型数据库构建预约应用
-
第十章, 使用仓储和文档数据库构建应用
第七章:一种对领域驱动设计的实用观点
领域驱动设计(DDD)是一组在现代企业应用程序中广泛使用的软件设计原则。它们在 2003 年由 Eric Evans 在其著作 领域驱动设计 中捆绑并推广。
您可能会想知道这与测试驱动开发(TDD)有什么关系。是因为它们有相似的缩写吗?现实是 TDD 和 DDD 在一起工作,其中 TDD 从客户端的角度覆盖设计和质量,而 DDD 补充了其余的设计。您将在对话和职位说明中听到这两个术语一起使用,到 第二部分,使用 TDD 构建应用 的结束时,原因将变得清晰。
本章旨在作为 DDD 的入门指南,因此您将获得构建一个完整应用所需的基础,该应用结合了 TDD 和 DDD。
DDD 是一个技术和哲学话题。鉴于本书的实用主义和本章的长度,我们的重点将限于与我们在以下章节中实施的应用相关的 DDD 的实用方面。
在本章中,我们将涵盖以下主题:
-
使用示例应用进行工作
-
探索领域
-
探索服务
-
探索存储库
-
整合一切
到本章结束时,您将了解基本的 DDD 术语,并能够向同事解释它。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch07
使用示例应用进行工作
我们需要一个示例应用来展示 DDD 概念。在您的项目中,“应用”这个词可能有多种含义。它可以是以下之一:
-
一个单一微服务,它是更大应用的一部分
-
一个单体应用,它是一个独立的应用
本章将使用单体应用,因为它更容易解释概念,并且上下文将更清晰。因此,我们将专注于 DDD 的具体细节,而不是深入更复杂的架构。
让我们以一个博客应用为例。一个 DDD 风格的博客应用在 Microsoft Visual Studio(VS)中可能看起来像这样:

图 7.1 – VS 中的博客应用
UQS 是我们虚构公司的缩写,代表 Unicorn Quality Solutions。这些项目之间有以下依赖关系:

图 7.2 – 项目依赖关系
这里没有什么特别的:这是一组常规的项目依赖关系。让我们深入了解每个项目角色。
应用项目
我们的项目与外界、客户端以及Uqs.Blog.WebApi项目进行通信。这个解决方案正在使用 RESTful Web API 与 UI 层通信。这应该使得基于浏览器的 UI 层,如React、Angular、Vue或Blazor,交换数据(以合同的形式)变得容易。
此外,它还可以作为一个独立的 API 项目,可以被称为无头博客,这是一个术语,意思是这只是一个没有 UI 的后端平台。多个 UI 可以与之交互,因此它不耦合于一个 UI。
这可能是一个标准的 ASP.NET Core Web API,类似于我们在前面的章节中使用过的。
在第二章 通过示例理解依赖注入 和 第三章 开始单元测试 中,你已经看到了WeatherForecastingController的示例。在 DDD 术语中,控制器充当了应用服务的角色。
合同对象项目
要与外界通信,您的数据应该有一个定义的结构。这个定义的结构包括类,它位于Uqs.Blog.Contract项目中。
如果这是一个 UI 项目,这些合同可能被称为视图模型,因为它们是直接绑定到 UI(视图)的模型。它们也可能被称为数据传输对象(DTOs),因为它们将数据从服务器传输到客户端。在 API 项目中,它们通常被称为合同。
简而言之,如果 RESTful API 有一个请求帖子完整信息的 API,其 URL 如下:
https://api.uqsblog/posts/1
然后,合同可能看起来像这样:
public record Author(int Id, string Name);
public record Post(int Id, string Content, Author Author,
DateTime CreatedDate, int NumberOfComments
, int NumberOfViews, …);
这通常以 JSON 的形式传输。这是前面 C#合同序列化为 JSON 的示例:
{
"id": 1,
"content": "Some content",
"author": {
"id": 100,
"name": "John Smith"
},
"createdDate": "2022-01-01T01:01:01",
"numberOfComments": 5,
"numberOfViews": 486,
…
}
合同不属于 DDD 哲学的一部分,但在这里需要它们来构建一个完整的应用程序。
领域层项目
这一层的组件位于Uqs.Blog.Domain中。这是所有与领域设计相关的类型所在的地方。
重要提示
分层、命名项目和根据层来安排它们是一个高度主观的过程。没有广泛认可的行业标准来定义最佳方法。因此,请将我这里的方法视为一个例子,而不是做事的方式。
这一层包含以下内容:
-
商业逻辑
-
数据库持久化
我们的项目与这个类似的设计:

图 7.3 – 应用程序的设计图
这个图表示我们的应用程序;然而,值得注意的是,DDD 关注的是后端,而不是客户端。
接下来,我们将探讨 DDD 的组成部分,并从领域开始。
探索领域
DDD 是一系列软件开发哲学和最佳实践。有几本书是专门关于 DDD 的,而且大多数都超过 500 页。因此,我们可以大谈 DDD,但本书不是关于 DDD 的,所以我们将简要介绍。
DDD 专注于业务逻辑以及与数据库和外部世界的交互,并采用一系列实践来确保软件设计的健壮性。DDD 中的领域一词指的是业务领域,可以是汽车保险、会计、账单、银行、电子商务等。DDD 强调业务领域,正如术语领域驱动所表示的。
接下来,我们将探讨构成 DDD 实际方面的架构组件。
领域对象
领域对象是现实生活商业实体的表示。在探索我们的博客项目时,领域对象可能如下所示:

图 7.4 – 贡献于博客领域的类和结构
你可以看到这些类型和属性的名称如何反映博客业务。这些实体通常直接映射到关系数据库表,因此你有帖子、作者、标签等数据库表。
在文档数据库中,领域对象可能直接持久化到您的集合中,也可能不是。
重要提示
DDD 没有规定领域对象应该映射到关系数据库表,但在实践中,这是实际的做法,尤其是在使用对象关系映射器(ORMs)如Hibernate(Java)和Entity Framework(.NET)的情况下。
你经常会发现诸如模型、业务对象和领域对象等词语被互换使用,意思相同。
并非所有领域对象都是平等的。DDD 区分两种类型的领域对象:实体和值对象。
实体和值对象
DDD 区分具有自身身份的对象,这些对象被称为Id属性,但它代表的是身份的普通英语含义。
值对象
值对象代表没有概念身份的已类型化值。最常见的值对象例子是货币。£5 纸币(钞票)没有身份,如果它被另一张£5 纸币所替换,那么什么都没有改变。换句话说,如果两个人交换了£5 纸币,那么他们具有相同的值,我们不必担心或追踪这些纸币。
重要提示
£5 纸币是一个值对象,除非纸币的序列号很重要。这可能是在英格兰银行发行货币项目的一部分。但在大多数情况下,这是一个值对象。
有许多例子可以说明什么是值对象。以下是一些例子:
-
日期
-
名字,因为单独的名字本身并不构成身份
-
地址
值对象在Tag结构体中建模。
博客标签,如 .NET、DDD 和 TDD,不需要 ID。但为了数据库存储,有一个标识符可能更实用,因为它允许更好地管理标签。
重要提示
从纯 DDD 方法来看,一个标签应该是 Post 中的一个属性,而不是一个独立的企业对象。然而,如果出现拼写错误,你想修正它怎么办?如果你想向用户显示一个现有标签列表以实现自动完成,将其作为一个独立的领域对象并存储在单独的表或容器中可能会带来更好的性能和管理。
在实践中,.NET 开发者很少使用结构体,除非他们正在构建一些底层的东西,例如性能优化,以及与未管理资源交互。通常,值对象是用类建模的,这并不非常符合 DDD。
实体
主要由其身份定义的对象称为 实体。它是一种需要随时间跟踪的领域模型,其属性可能随时间而变化。一个完美的例子就是人员实体,它有一个可变的电子邮件和家庭地址,但有一个固定的身份,即本人。
在我们之前的博客示例中,Post、Author、Comment 和 Commenter 都是实体。
Comment 是一个特殊的情况,因为有人可能会认为它是一个值类型!但如果它是可编辑的,那么它的身份就变得很重要。
实体以类和记录的形式表示,并且它们肯定有一个 标识符(ID)。
实体与值对象
在设计你的领域时,理解这些差异非常重要,这样你才能选择正确的设计。以下是主要的区分方面:
-
生命周期:实体存在于连续体中,而值对象可以轻松创建和销毁。
-
不可变性:如果一个对象在创建后其值不能改变,则称该对象为不可变。实体是可变的,而值对象是不可变的。
-
标识符:实体对象需要一个标识符,而值对象不需要。
-
类或结构体:实体使用类并遵循 .NET 引用类型原则(存储在堆中,通过引用传递等),而值类型是结构体(至少 DDD 推荐如此),它们遵循 .NET 值类型原则(存储在栈中,通过值传递等)。
总结来说,当我们设计我们的领域对象时,它们可以是实体或值对象,这取决于它们是否代表一个身份。
聚合
聚合是一组形成单一业务目标的类。之前的博客类设置了一个显著的业务目标,即管理博客文章。这些类形成一个聚合。
重要提示
在 面向对象编程(OOP)和 统一建模语言(UML)中使用的 聚合 术语与 DDD 聚合的概念并不相同。
一个 Post 领域对象。
贫血模型
当我们学习面向对象编程时,我们了解到对象处理自己的数据和自己的行为。因此,如果我们有一个名为Person的类,那么在这个类中可能有一个只读属性Email。此外,要设置电子邮件地址,你可能会有一个可能被称为void ChangeEmail(string email)的方法,该方法在设置电子邮件之前执行一些业务逻辑和验证。根据 DDD,我们的类将看起来像这样:
public class Person
{
public string Email { get; private set; }
public void ChangeEmail(string email)
{
…
}
// other properties and methods
}
这个类存储了自己的数据。例如,Email属性存储电子邮件值,并且有一个行为,由ChangeEmail方法表示,该方法是更改存储的Email。
之前的一个Person类到贫血版本的例子:
public class Person
{
public string Email { get; set; }
// other properties
}
现在电子邮件有了设置器,但如果它不在类内部,如何实现验证和其他业务逻辑?答案是,另一个类将负责,如下所示:
public class PersonService
{
public void ChangeEmail(int personId, string email)
{
Person person = …; // get the object some how
// validate email format
// check that no other person is using the email
person.Email = email;
}
}
在这种情况下,另一个类PersonService处理Person类的行为,Person类的行为越外包给其他类,Person类就越贫血。
在贫血模型中,客户端解释领域对象的目的和使用,而业务逻辑最终在其他类中实现,类似于前面的例子。贫血模型被认为是一种反模式,因为它与面向对象编程的理论相悖。
然而,在领域对象中使用贫血模型的反模式在开发者之间非常普遍,因为设置 ORM(如Entity Framework(EF))和其他实际操作与 DDD 最佳实践相冲突。
本书剩余部分采用贫血模型方法,因为它在市场上占主导地位。它更实用,并且与 ORM(对象关系映射)配合良好。
普遍语言
词语普遍存在(ubiquitous),发音为yu-bikwitus,根据剑桥词典的定义,意味着无处不在。
从 DDD 的角度来看,这意味着在命名领域对象时使用众所周知的术语,类似于业务人员所使用的术语。换句话说,不要发明自己的术语,而是遵循现有的语言:业务语言。
这种方法有几个明显的优点:
-
业务利益相关者和开发者之间的对话更加流畅
-
新的开发者可以快速处理业务逻辑和代码。
我在博客示例中确实使用了这种方法,其中我使用了博客中使用的术语。同样的概念也适用于更大规模的项目。
到目前为止,你对 DDD 中的领域对象和聚合体已经有了大致的了解。在下一节中,我们将深入探讨一个主要的 DDD 主题,我们将在本书的第二部分,即使用 TDD 构建应用程序中广泛使用这个主题。
探索服务
服务在领域驱动设计(DDD)中分为三种类型,但我们将目前专注于领域服务,稍后我们将讨论其他两种:基础设施服务和应用服务。
域服务是 DDD 生态系统中包含业务逻辑的单元。域服务有以下职责:
-
通过仓库的帮助加载域对象
-
应用业务逻辑
-
在仓库的帮助下持久化域对象
理解域服务不知道数据是如何从存储介质加载的以及如何存储的非常重要。它们只知道如何通过数据仓库请求数据加载或持久化操作。仓库将在本章后面介绍。
让我们为我们的博客项目添加一些服务,以帮助我们发布帖子、检索和更新它们。
帖子管理
如果你曾经发布过博客文章或在线文章,你将熟悉这个过程。如果你打开文本编辑器来撰写博客文章,你必须填写标题、内容和其他字段,但你也可以在不完成所有内容的情况下保存。在编辑时,不填写必填字段是可以的,但当你想要发布时,一切都应该完成。
让我们开始实现管理帖子所需的域服务。
添加帖子服务
添加新帖子将需要作者的 ID 但不需要其他字段。此服务的代码可以看起来像这样:
public class AddPostService
{
private readonly IPostRepository _postRepository;
private readonly IAuthorRepository _authorRepository;
public AddPostService(IPostRepository postRepository,
IAuthorRepository authorRepository)
{
_postRepository = postRepository;
_authorRepository = authorRepository;
}
public int AddPost(int authorId)
{
var author = _authorRepository.GetById(authorId);
if (author is null)
{
throw new ArgumentException(
"Author Id not found",nameof(authorId));
}
if (author.IsLocked)
{
throw new InvalidOperationException(
"Author is locked");
}
var newPostId = _postRepository.CreatePost
(authorId);
return newPostId;
}
}
首先,你会注意到我专门创建了一个包含单个方法 AddPost 的类 AddPostService。一些设计创建了一个单一的 PostService 服务类,并在其中添加了多个业务逻辑方法。我选择了在单个类中只有一个公共方法的方法,以尊重 SOLID 的单一职责原则。
我已将两个仓库注入到类中,这些仓库对于业务逻辑是必需的:author 和 post 仓库。关于依赖注入的提醒,请参阅 第二章,通过示例理解依赖注入。
我实现了一个业务逻辑,用于检查是否将不存在的作者传递给方法。如果作者被锁定以发布,则创建一个帖子并返回创建的 ID。我本可以使用 Guid,但 UI 会想要一个整数。
这里值得注意的是,服务并不知道如何加载 Author。它可能来自关系型数据库、文档数据库、内存数据库,甚至是一个文本文件!服务将这种知识委托给了仓库。
此处的服务专注于单一职责,即添加新帖子的业务逻辑。这是一个关注点分离的例子。
更新标题服务
博客的标题可以长达 90 个字符,并且可以随时更新。以下是一个实现此功能的示例代码:
public class UpdateTitleService
{
private readonly IPostRepository _postRepository;
private const int TITLE_MAX_LENGTH = 90;
public UpdateTitleService(IPostRepository postRepo)
{
_postRepository = postRepo;
}
public void UpdateTitle(int postId, string title)
{
if (title is null) title = string.Empty;
title = title.Trim();
if (title.Length > TITLE_MAX_LENGTH)
{
throw new
ArgumentOutOfRangeException(nameof(title),
$"Title max is {TITLE_MAX_LENGTH} letters");
}
var post = _postRepository.GetById(postId);
if (post is null)
{
throw new ArgumentException(
$"Unable to find a post of Id {postId}",
nameof(post));
}
post.Title = title;
_postRepository.Update(post);
}
}
上述逻辑很简单。这里的新颖之处在于服务加载实体、修改其属性之一,然后请求仓库管理更新操作的方式。
在这两个服务中,涉及的业务逻辑对数据平台没有任何了解。这可能包括 SQL Server、Cosmos DB、MongoDB 等等。DDD 将这些工具的库称为 基础设施,因此服务对基础设施没有任何了解。
应用程序服务
之前,我们描述了领域服务。应用程序服务提供与外部世界的交互或粘合剂,允许客户端从你的系统中请求某些内容。
应用程序服务的完美例子是一个 ASP.NET 控制器,其中控制器可以使用领域服务来响应 RESTful 请求。应用程序服务通常使用领域服务和仓库来处理外部请求。
基础设施服务
这些用于抽象技术问题(云存储、服务总线、电子邮件提供者等等)。
我们将在本书的 第二部分,使用 TDD 构建应用程序 中广泛使用服务。所以我希望你对它们有了概念。稍后,我们将有一个涉及多个服务的端到端项目。
服务特征
关于如何在 DDD 中构建服务有一些指导原则。我们在这里将讨论其中的一些。然而,如果你想了解更多,我建议阅读本章末尾的 进一步阅读 部分。
我们将讨论无状态服务、通用语言以及使用领域对象而不是服务。
无状态
服务不应该持有状态。持有状态类似于记住数据,也就是说,用简单的话说,在服务类的字段或属性中持久化一些业务数据。
避免在服务中维护状态,因为这会复杂化你的架构,如果你认为你需要状态,那么这就是仓库的作用所在。
使用通用语言
像往常一样,使用通用语言。在之前的例子中,我们按照业务操作命名服务和方法。
在相关的地方使用领域对象
DDD 反对贫血模型,因此它鼓励用户检查领域模型是否可以执行操作,而不是在服务中执行此操作。
在我们的例子中,DDD 会鼓励我们在 Post 中有行为(公共方法)。如果我们遵循 DDD 的建议,我们的 Post 类将看起来像这样:
public class Post
{
public int Id { get; private set; }
public string? Title { get; private set; }
// more properties…
private readonly IPostRepository _postRepository;
private const int TITLE_MAX_LENGTH = 90;
public Post(IPostRepository postRepository)
{
_postRepository = postRepository;
}
public void UpdateTitle(string title)
{
…
}
}
注意,属性的设置器现在是私有的,因为只有类内的方法可以设置属性。第二个要注意的是,UpdateTitle 方法不需要获取 Id 作为参数,因为它可以从类内部访问 Id。它只需要新的标题。
这种做法的好处是,你的类不是贫血的,并且遵循 OOP 原则。显然,我们没有遵循 DDD 的建议,在服务类中编写了 UpdateTitle 方法。
我这样做不是为了惹恼 DDD 实践者,而是出于实用目的!让我列出在使用 EF(主要的 .NET ORM)时,采用这种方法可能遇到的一些潜在问题。
-
在运行时,
Post类。这不是一个常见的做法,我甚至不确定这是否可能通过非黑客代码实现。 -
从数据库中获取
Post,它将无法设置属性,这使得 EF 无用。 -
业务逻辑的分布:如果领域类包含业务逻辑,有时你的业务逻辑会在服务中,有时会在领域对象中,而不是在两者之一中。换句话说,它将在多个类中分布。
有方法可以使这行得通,但它们不值得付出努力。在这里,实用性不符合 DDD 理论,这就是我选择使用贫血领域对象的原因。得到的启示是,你知道 DDD 是什么,为什么会有这样的倡导,以及为什么我们要从这种做法转变。
服务不关心数据是如何加载和持久化的,因为这由仓储负责,这自然引出了下一个话题。
探索仓储
仓储是属于基础设施的类。它们理解底层存储平台并与数据存储系统的具体细节交互。
它们不应该包含业务逻辑,而应该只关注加载数据和保存数据。
仓储是一种通过让服务和领域负责业务逻辑但不负责数据持久性来实现单一责任(如 SOLID 的单一责任原则)的方法。DDD 将数据持久性责任赋予仓储。
仓储的一个例子
你之前在 UpdateTitleService 类中见过这一行代码:
var post = _postRepository.GetById(postId);
这里,我们将向你展示 GetById 的一个潜在实现。
使用 Dapper 与 SQL Server
Dapper 是一个被归类为 微 ORM 的 .NET 库。它非常受欢迎,并在 StackOverflow 上使用。
Dapper 可以用来访问 SQL Server 数据库,所以假设我们的博客数据库是 SQL Server 类型,我们将使用 Dapper 实现 PostRepository 的 GetById。
要在任何项目中使用 Dapper,您可以通过 System.Data.SqlClient NuGet 进行安装:
using Dapper;
using System.Data.SqlClient;
…
public interface IPostRepository
{
int CreatePost(int authorId);
Post? GetById(int postId);
void Update(Post post);
}
public class PostRepository : IPostRepository
{
public Post? GetById(int postId)
{
var connectionString = … // Get con string from config
using var connection = new SqlConnection
(connectionString);
connection.Open();
var post = connection.Query<Post>(
"SELECT * FROM Post WHERE Id = @Id", new {Id =
postId}).SingleOrDefault();
connection.Close();
return post;
}
…
}
通常,仓储类有一个接口对应物,以便它们可以被注入到服务中。注意,在我们的上一个 PostService 中,我们已经注入了 IPostRepository。代码展示了仓储的工作方式,但它不符合 DI 规范,然而,在下一节中将会。
SqlConnection 类是一个 ADO.NET 类,它允许你管理与 SQL Server 数据库的连接。
Query() 是 Dapper 提供的一个扩展方法。它允许你发出一个常规的 T-SQL 查询并将结果映射到对象。
使用 Dapper 与 SQL Server 和 DI
如你所注意到的,我们没有注入 SqlConnection,而是直接在代码中实例化了它。显然,这不是最佳实践!以下是一个利用注入连接对象的实现:
public class PostRepository : IPostRepository
{
private readonly IDbConnection _dbConnection;
public PostRepository(IDbConnection dbConnection)
{
_dbConnection = dbConnection;
}
public Post? GetById(int postId)
{
_dbConnection.Open();
var post = _dbConnection.Query<Post>(
"SELECT * FROM Post WHERE Id = @Id", new {Id =
postId}).SingleOrDefault();
_dbConnection.Close();
return post;
}
…
}
SqlConnection 实现 IDbConnection,我们可以在启动时的 DI 部分将其连接起来,以在运行时注入正确的对象(此处未显示,因为这只是一个虚构的示例)。DI 将负责实例化连接对象,所以我们在这里不需要做。
GetById 方法使用 Dapper 的 ADO.NET 扩展方法将查询结果映射到 C# 对象。有更干净的方法可以实现这一点,但在这个例子中,我选择了最易读的一种。
使用其他数据库
在前面的例子中,我们使用了 SQL Server 数据库;然而,任何其他数据库都可以。唯一会改变的是 PostRepository 类内部的实现。IPostRepository 的消费者不会改变。
在接下来的章节中,我们将演示使用 SQL Server(与 EF)和 Cosmos DB 的端到端实现。
EF 和仓储
EF 是 .NET 的主要 ORM。ORM 是一个术语,表示它将关系型数据库记录加载到对象中。
EF 提供了高级抽象,体现了多个 DDD 模式,最显著的是仓储模式。当使用 EF 时,仓储模式消失,取而代之的是 EF,代码设计变得更简单。
在本章中,了解这一点就足够了。在 第九章,“使用 Entity Framework 和关系型数据库构建预约应用”,我们将有一个完整的实现,包括 EF 和一个完全工作的源代码,这将阐明从端到端是如何完成的。
将一切整合在一起
这是我最喜欢的一部分。我一直在这里那里提供一些小片段,希望现在你能看到从 DDD 视角看一切是如何相互关联的。我已经将这些片段包含在源代码目录中。
解决方案资源管理器视图
在这个项目中,我们所做的是一系列片段的集合。让我们看看它们:

图 7.5 – 从 DDD 视角看 VS 解决方案文件
让我们回顾一下每个项目:
-
契约:这是外部世界看到的内容。这些契约代表了将在后端和客户端之间交换的数据的形状。客户端应该知道契约的数据元素,以便知道从你的无头博客中可以期待什么。
-
实体:它们是有身份的领域对象。
-
值对象:它们是不需要身份的领域对象。
-
领域对象:这是系统中实体和值对象的集合。
-
仓储:这些是保存和加载数据到数据存储(关系型数据库、文档数据库、文件系统、博客存储等)的类。
-
领域服务:这是业务逻辑将驻留的地方,它将与存储库进行 CRUD 操作的交互。这些服务不会暴露给外界。
-
REST请求。应用服务暴露给外界。
偶然的是,我们只有一个聚合,那就是我们所有的领域对象。一个领域可能包含多个聚合。我们还有 Post 作为我们的聚合根。
架构视图
我们已经看到了我们 DDD 项目的潜在项目和文件结构,现在,让我们从软件设计的角度来审视它:

图 7.6 – DDD 的简化软件设计视图
让我们讨论这个 DDD 风格的系统:
-
应用服务:它们与客户端和领域服务交互。根据合同,它们将数据传递给客户端,并直接处理领域服务。
-
领域服务:它们为 应用服务 提供服务。
-
基础设施服务:它们提供不属于领域的服务,例如获取 ZIP 码/邮编城市。
-
聚合:每个聚合包含多个领域对象,并有一个聚合根。
-
领域对象:它们是所有聚合中的所有实体和值对象。
我希望我能够从编码和项目结构以及从架构的角度展示 DDD 设计的基础,尽管冒着重复概念两次的风险。
摘要
在 DDD 中有一些我没有涉及的主题,因为它们没有直接贡献于本书的其余部分,例如边界上下文、领域事件、工作单元等。我在 进一步阅读 部分提供了额外的资源,帮助你进一步探索这些概念。
我们已经讨论了 DDD 的基础知识,并期待这一章能让你熟悉这个概念,这样我们就可以在后续章节中自由使用诸如 领域对象、领域服务 和 存储库 等术语,而无需你皱眉。我们还看到了 DDD 不同组成部分的示例代码。
我们还看到了在什么情况下我们可以从 DDD 指南中做出调整,使其更加实用,并解释了原因。
在下一章中,我们将为利用你迄今为止所学的一切知识(包括 DDD)的完整项目打下基础。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下资源:
-
《领域驱动设计》,作者:Eric Evans,Addison-Wesley(2003)
-
《实现领域驱动设计》,作者:Vaughn Vernon,Addison-Wesley(2013)
-
《.NET Core 实战领域驱动设计》,作者:Alexey Zimarev,Packt Publishing(2019)
-
设计面向 DDD 的微服务:
docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice -
马丁·福勒关于 DDD:
martinfowler.com/bliki/DomainDrivenDesign.xhtml -
快速入门:使用 .NET V4 SDK 构建 console 应用程序以管理 Azure Cosmos DB SQL API 资源:
docs.microsoft.com/en-us/azure/cosmos-db/sql/create-sql-api-dotnet-v4 -
GitHub 上的 Dapper:
github.com/DapperLib/Dapper
第八章:设计预约应用程序
在前面的章节中,我们看到了范围有限的示例实现,因为在每个涵盖的主题上都有完整的应用程序是不切实际的。
本章涵盖了理发预约应用程序的设计,它将结合我们从前面章节中学到的内容:
-
依赖注入
-
单元测试
-
使用模拟和伪造的测试替身
-
DDD
-
应用 TDD
第九章 和第十章将涵盖本章的实现。本章是关于业务需求和设计决策,而不是关于实现(代码)。
在继续本章和第二部分的其他内容之前,我强烈建议您熟悉我列出的上述主题。它们都在 第二章 到 第七章 中有所涉及。
在本章中,我们将涵盖以下内容:
-
构建预约系统的业务需求
-
系统的 DDD 风格设计
-
该系统的实现路线
到本章结束时,您将更好地理解基于真实问题的 DDD 分析。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch08
收集业务需求
您为名为 Unicorn Quality Solutions(UQS)的软件咨询公司工作,该公司正在为拥有许多员工的现代理发店 Heads Up Barbers 实施预约应用程序。
必需的应用程序将包括三个应用程序:
-
预约预约网站:客户将在这里预约理发。
-
预约预约移动应用程序:与网站相同,但是一个原生移动应用程序(而不是移动网络浏览器上的网站)。
-
后台网站:这是一个供企业主使用的内部应用程序。它为理发师(员工)分配班次,取消预约,计算理发师的佣金等。
第一阶段 的交付仅是第一个应用程序(预约网站),因为它具有最高的商业价值,因为它允许用户通过桌面和他们的移动网络浏览器进行预约。
这是我们关注本书第二部分其余部分的内容。以下是一个显示项目三个阶段的图表:

图 8.1 – 必需的三个应用程序
虽然我们只关注构建 第一阶段,但我们需要在设计时考虑我们的架构将在后期阶段包括对移动应用程序的支持。
业务目标
在这个时代,大多数客户喜欢在线预约,尤其是在 COVID-19 之后,商店试图通过预约来减少空间中的人流集中。
Heads Up Barbers 希望有一个预订解决方案,旨在做到以下几方面:
-
推广可用的理发服务。
-
允许客户预约特定或随机的理发师。
-
在预约之间给理发师休息时间,通常是 5 分钟。
-
理发师在商店有不同的班次,他们在不同的日子休息,因此解决方案应该根据理发师的可用性来选择空闲时段。
-
通过不必在电话或亲自安排预约来节省时间。
Stories
在分析了业务目标后,UQS 提出了更详细的需求,以用户故事和原型形式呈现。我们将在下面进行介绍。
Story 1 – 服务选择
作为一位客户:
我希望有一个所有可用服务及其费用的列表。
这样我就可以选择一个进行预订。
并被转到预订页面。

图 8.2 – 可用服务及其价格的列表
这个原型展示了所有可用的服务及其价格,以及选择超链接,将用户带到所选服务的预订页面。
Story 2 – 默认选项
作为一位客户:
我想有一个预订页面,默认选中[任何员工]和今天的日期。
这样我就可以节省点击时间,更快地完成预订。

图 8.3 – 默认选项已选的预订页面
注意到[任何员工]和当前日期,2022-04-03,默认选中。
Story 3 – 选择员工
作为一位客户:
我希望为我的预约选择任何员工或特定员工。
这样我就可以选择我喜欢的理发师,如果有的话。

图 8.4 – 选择特定员工
客户将有一份来自 Heads Up Barbers 的理发师名单,他们可以从中选择他们最喜欢的一位。
Story 4 – 预约日期
作为一家企业:
我们希望向客户展示最多 7 天的窗口,包括当前日期,以便选择预约。
如果所选员工不完全可用,我们希望减少这个窗口。
这样我们可以保证我们的员工在预订时的可用性。

图 8.5 – 显示从 2022-04-03 开始的 7 天窗口的日历
原型将考虑所选员工日程的变化,并仅显示所选员工的可用时间窗口。
Story 5 – 时间选择
作为一家企业:
我希望向客户展示所选日期所选员工可用的时段。
并考虑现有员工的预约和员工的班次。
将任何预约向上取整到最近的 5 分钟。
并考虑预约之间的 5 分钟休息时间。
因此,我确保客户正在选择一个已经可用的员工。

图 8.6 – 为所选日期的员工可用的时段
让我们举几个例子来澄清要求。
注意所有分钟数都是 5 的倍数。
第 1 个示例 – 没有可用的班次
如果员工在所选日期没有分配的班次,列表将为空,客户将无法预订。
第 2 个示例 – 没有预约
员工汤姆在 2022-10-03 日 9:00 至 11:10 有班次,并且没有已预订的预约。客户想要预订一个 30 分钟的服务。所选的起始时间将有以下值:09:00,09:05,09:10,……,10:35,和 10:40。
第 3 个示例 – 在班次结束时预订多个预约
员工汤姆在 2022-10-03 日 9:00 至 11:10 有班次,但他已经从 09:35 至 11:10 预订了预约。客户想要预订一个 30 分钟的服务。所选的起始时间将有以下值:09:00。以下图示说明了时间段:

图 8.7 – 一个带有休息间隔的时段
第 4 个示例 – 在班次结束时预订多个预约
汤姆在 2022-10-03 日 9:00 至 11:10 有班次,但他已经从 09:40 至 11:10 预订了预约。客户想要预订一个 30 分钟的服务。所选的起始时间将有以下值:09:00 和 09:05。

图 8.8 – 两个带有休息间隔的时段
第 5 个示例 – 在班次中间预订预约
汤姆在 2022-10-03 日 9:00 至 11:10 有班次,但他已经从 09:40 至 10:35 预订了预约。客户想要预订一个 30 分钟的服务。所选的起始时间将有以下值:09:00,09:05 和 10:40。

图 8.9 – 三个带有两个休息间隔的时段
第 6 个故事 – 填写名字
作为客户:
当我出现在理发店时,我必须填写我的名字和姓氏以作为我的身份证明。
因此,我是唯一识别的。

图 8.10 – 姓氏和名字字段
第 7 个故事 – 服务显示
作为客户:
我想要提醒我选择的服务名称、价格和所需时间。
因此,在点击预订按钮之前,我可以回顾我的选择。
第 8 个故事 – 所有字段都是必填验证
作为客户:
在预订之前,我必须选择并填写所有字段。
因此,我不会得到验证错误。
第 9 个故事 – 随机选择任何员工
作为一家企业:
当[任何员工]被选中时。
并且在所选时间段内有多名员工空闲。
然后我点击预订。
随机选择一名空闲员工。
因此,我确保我们的员工在预约中公平分配。
示例 1 – 在一个时间段内有三名员工空闲
如果客户选择[任何员工]并得到三名空闲的员工(托马斯、简和威廉),并且客户选择09:00并点击预订,托马斯、简或威廉将被随机分配到预约,不考虑任何其他因素,并从中选择一个。
故事 10 – 确认页面
作为客户:
我想查看我的预约是否已预订。
因此,我可以放心,它正在进行中。

图 8.11 – 确认页面
上面的确认页面是一个简单的静态页面。
你可能已经感觉到,从业务逻辑的角度来看,故事 5 是最具有挑战性的,这将是我们的单元测试的重点。
如您所见,实现的范围是有限的。在未来,我们可以通过以下方式进一步扩展:
-
在线支付
-
用户登录
-
邮件确认
-
更多…
然而,到目前为止的故事描述了一个健壮的、逼真的系统。有些人可能会称之为最小可行产品(MVP);然而,我不会这样称呼它,因为这可能会错误地暗示系统质量较低。
现在是时候从业务需求转向设计我们系统的通用指南了。
以 DDD 精神进行设计
在上一章中,我们学习了 DDD 的概述。在我们的实现中,我们将遵循 DDD 的精神来设计业务类。
领域对象
如果我们阅读所有故事并思考领域模型,我们可能会得出以下类:

图 8.12 – 领域类的图示
-
AppointmentTimeSpanInMin代表服务的持续时间,IsActive为真以向客户端提供。 -
客户:表示客户。我们目前只关注他们的名字。
-
员工:这个类将在稍后阶段扩展以包含更多信息,但到目前为止,我们只需要名字。
-
班次:代表理发师独特的可用时间。后台应用程序(不在范围内)将允许业务所有者每天为员工添加班次,以覆盖至少 7 天。因此,无论何时向客户展示日期选择,我们都有至少 7 天的未来日期。
-
预约:很明显,预约将一项服务与员工和客户联系起来。它还指定了预约的开始和结束时间。
在我们的实现中,我们有一个包含所有先前类的单一聚合体,我们的聚合根显然是Appointment类。
领域服务
领域服务包含控制系统行为的业务逻辑。我们的系统将处理四类业务逻辑,这可能导致四个领域服务:

图 8.13 – 领域服务初步设计
目前阶段的服务只是一个初步设计。你通常是通过 TDD(测试驱动开发)过程来驱动服务设计,而不是预先设计服务,这通常是一个接一个的顺序进行。
系统架构
虽然我们现在只进行系统的第一阶段,但考虑到下一个阶段将实现使用与预订网站相同逻辑的移动应用,我们的架构应该为未来的阶段做好准备。考虑到这一点,下一个图中的架构可以支持所有阶段:

图 8.14 – 架构设计
使用一个后端来支持所有客户端将嵌入一个业务逻辑来支持所有客户端,因此我们所有的业务逻辑都将位于我们的 RESTful API 应用程序后面。
此外,这会使我们的后端作为一个由一个项目中的 API 集合和一个单一数据库组成的单体应用程序。这是可以接受的,因为这个项目范围有限,走微服务路线将是过度设计。
这是一个众所周知的架构模型,其中你将业务逻辑隐藏在 Web API 后面以支持多个客户端,并使逻辑集中化。在未来的阶段添加预订移动应用和后台 Web 应用时,不应重构架构。
实施路线
我们将以不同的方式实现后端。每种实现都将产生相同的 API 结果,但这样做的目的是在每个实现中体验多个单元测试和测试替身场景。
您的团队可能正在使用这些架构路线之一,因为他们可能正在使用文档数据库或关系数据库,正如大多数现代应用的情况一样。
前端
在这本书中,我们更关注后端,因此,前端上的 TDD 实现没有涵盖。
重要注意事项
有一些单元测试框架可以测试前端。Blazor 的一个流行库,我们在这里将使用它,是bUnit,它与 xUnit 并行工作。
在所有流行的 JavaScript单页应用(SPA)平台,如 React、Angular 和 Vue 中,我决定使用微软的Blazor来实现前端。
Blazor 是一个基于 C#而不是 JavaScript 的 Web 框架。简单来说,Blazor 将 C#转换为浏览器理解的底层语言WebAssembly(Wasm)。
我选择 Blazor,因为我假设对于没有 SPA(单页应用)经验或 JavaScript/TypeScript 经验的 C#开发者来说,它会更简单。
前端实现是最简的,故事部分之前的模拟截图来自 Blazor 应用程序。你可以在本章的 GitHub 上的Uqs.AppointmentBooking.Website找到它。
重要提示
前端实现旨在可读性和简约,而不是网页设计、用户体验、健壮性和最佳实践。
要启动网站:
-
在 VS 中打开
UqsAppointmentBooking.sln。 -
右键单击
Uqs.AppointmentBooking.Website并选择设置为启动项目。 -
从 VS 运行。
随意运行网站并四处点击。你会注意到它是模拟的,因此它不依赖于真实数据库,而是依赖于示例数据。关于前端的内容仅限于本节,因为本书的重点是 TDD 和后端。
关系型数据库后端
通常,使用 SQL Server 和 Oracle 等关系型数据库会邀请Entity Framework(EF)。后端依赖于 EF 会影响你组织测试和将要使用的测试替身类型的方式。
第九章, 使用 Entity Framework 和关系型数据库构建预约预订应用程序,将致力于使用关系型数据库(SQL Server)和 EF 来实现需求。
文档数据库后端
当使用 Cosmos DB、DynamoDB 和 MongoDB 等文档数据库时,你不会使用 EF。这意味着你将实现更多的 DDD 模式,如存储库模式。这将使使用文档数据库的实现与使用 EF 的实现相比,在测试替身和依赖注入(DI)方面相当不同。
第十章, 使用存储库和文档数据库构建应用程序,将重复第九章第九章的实现,但大约有 50%的代码不同,因为它将使用文档数据库。
展示这两个版本将让你看到实现之间的差异,并希望促进你对测试替身和 DI 的理解。然而,如果你只对特定类型的数据库感兴趣,那么你可以选择第九章或第十章。
好消息是,这两章之间有重复,你将能够轻松地发现它们并专注于独特的实现。
使用中介者模式
当使用中介者模式时,你所有的设计更改、测试和测试替身都会相应地进行。中介者模式是一把双刃剑;它有一个陡峭的学习曲线,但一旦学习和实施,它提供了更高层次的组件关注分离。它还会改变你的单元测试结构。中介者模式超出了本书的范围,这里提到它是为了指导你发现影响你的 DI 实现和单元测试的相关模式。
希望到 第二部分 的结尾,你能够真正地感受到如何在更现实的环境中实施 TDD。
摘要
我们看到了相当合理的需求,我们也看到了系统的潜在设计。本章是 将所有内容整合在一起 的开始。
你也看到了一个基于 DDD 的设计,这个设计将在后面的章节中转化为代码。我们还讨论了会影响我们测试和测试替身方式的实现路线。
复杂和现代的项目使用 DDD 的概念。到目前为止,在分析完一个完整的项目后,我希望 DDD 的术语开始听起来熟悉,并帮助你构建你的下一个项目,以及帮助你与专家开发者进行沟通。
下一章是对本章内容的实现,但重点在于 SQL Server 和 EF。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下链接:
- Mediator 是 .NET 中流行的 NuGet 库:
github.com/jbogard/MediatR
第九章:使用 Entity Framework 和关系型数据库构建预约预订应用
在上一章中,我们概述了为名为 Heads Up Barbers 的理发店构建预约预订系统的技术规范和设计决策。本章是第八章,设计预约预订应用的延续,因此我强烈建议您首先熟悉那章的内容。
本章将采用 TDD 风格实现需求,并使用Entity Framework(EF)和 SQL Server。实现将适用于其他关系型数据库管理系统(RDBMSs)如 Oracle DB、MySQL、PostgreSQL 等。
如果您是关系型数据库的粉丝或者在您的工作中使用关系型数据库,那么这一章就是为您准备的,而如果您使用的是文档数据库,那么您可能想要跳过这一章,直接进入下一章。第九章Chapter 9和第十章Chapter 10都有相同的成果,但它们使用不同类型的后端数据库。
我假设您熟悉 EF 以及它的配置和使用。然而,如果您不熟悉,我鼓励您首先熟悉它。
在本章中,我们将涵盖:
-
规划代码和项目结构
-
使用 TDD 实现 WebApis
-
回答常见问题
到本章结束时,您将体验使用模拟和伪造进行 TDD 的端到端应用程序的实现。同时,您还将见证编写单元测试之前的分析过程。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch09
要运行项目,您需要安装 SQL Server 的一个版本。这可以是 Azure SQL、SQL Server Express LocalDB 或任何其他 SQL Server 版本。
实现过程中没有使用任何高级 SQL Server 功能,因此您可以自由使用任何功能。我已经使用 SQL Server Express LocalDB 测试了应用程序。您可以在以下链接中了解更多信息:
docs.microsoft.com/en-us/sql/database-engine/configure-windows/sql-server-express-localdb
您也可以使用任何其他 RDBMS,但您需要在代码中将数据库提供者更改为使用特定的.NET 数据库提供者。
要运行项目,您必须修改Uqs.AppointmentBooking.WebApi/AppSettings.json中的连接字符串以指向您的特定数据库实例。目前,它设置为:
"ConnectionStrings": {
"AppointmentBooking": "Data
Source=(localdb)\\ProjectModels;Initial
Catalog=AppointmentBooking;Integrated Security=True;…"
},
连接字符串指向LocalMachine,并将连接到名为AppointmentBooking的数据库。
如果你决定使用替代的关系型数据库管理系统(RDBMS),那么你将不得不在Uqs.AppointmentBooking.WebApi中安装相关的 NuGet 包,并将同一项目中Program.cs的以下代码更改为你的特定 RDBMS:
builder.Services
.AddDbContext<ApplicationContext>(options =>
options.UseSqlServer(
builder.Configuration
.GetConnectionString("AppointmentBooking")
));
之前的数据库配置步骤是可选的。你可以不使用数据库来实现本章的要求,但你将无法运行项目并在浏览器中与之交互。
规划代码和项目结构
在第八章“设计预约预订应用”中,我们规划了领域并分析了我们需要做什么。项目架构将遵循客户端应用(网站)、业务逻辑(Web API)和数据库(SQL Server)的经典三层应用。让我们将其转换为 VS 解决方案和项目。
在本节中,我们将创建解决方案,创建项目,并连接组件。
分析项目结构
让一组资深开发者提出一个项目结构,你最终会得到多个结构!在本节中,我们将讨论一种我多年来开发的项目结构组织方法。
由于我们首先将构建一个面向用户的网站,然后是一个移动应用(本书未涵盖),因此将业务逻辑隔离到一个可以被网站和移动应用共享的 WebApi 项目中是有意义的。因此,我们将基于 Blazor WebAssembly 构建一个名为Uqs.AppointmentBooking.Website的网站项目。
领域逻辑将以 API 的形式公开,因此我们将创建一个名为Uqs.AppointmentBooking.WebApi的 ASP.NET API 项目用于 API。
前两个项目需要在一个称为Uqs.AppointmentBooking.Contracts的约定结构中交换数据。此项目将由网站和 WebApi 项目引用。
WebApi 项目将网络请求转换为我们可以用 C#理解的内容。从技术角度来说,这将管理基于 RESTful 风格的 API 的 HTTP 通信层。因此,WebApi 项目将不包含业务逻辑。业务逻辑将位于我们的领域项目中。我们将创建一个名为Uqs.AppointmentBooking.Domain的领域项目。
你的业务逻辑将存在于两个地方——UI 和领域层。UI 业务逻辑将管理 UI 功能,如切换下拉菜单、锁定日历日、响应拖放操作以及启用/禁用按钮等。这种逻辑将存在于网站项目中。编写代码使用的语言取决于所使用的 UI 框架,例如 Angular、React 和 Blazor。通常,你不会使用 TDD 来实现 UI 项目的功能,但你可以使用单元测试。在我们的实现中,UI 层将包含少量代码,因此我们不会进行任何 UI 单元测试。
复杂的业务逻辑将存在于域层,我们将遵循 TDD 的概念来编写它。因此,我们将创建一个项目来保存我们的域单元测试,并将其命名为 Uqs.AppointmentBooking.Domain.Tests.Unit。
为了将这些项目置于正确的视角并将它们映射到我们的三层架构,我们可以有以下图示:

图 9.1 – 项目与应用设计之间的关系
之前的图示显示了每个项目提供的功能,以形成三层应用程序。让我们首先创建 VS 解决方案结构。
创建项目和配置依赖关系
这是不可避免的枯燥部分,创建解决方案和项目并将它们链接在一起。在下一节中,我们将采用命令行方法而不是 UI 方法。
注意
我已经将一个名为 create-projects.bat 的文本文件添加到项目源代码控制中,其中包含所有命令行,因此您不必手动编写它们。您可以将此文件复制并粘贴到您想要的目录中,然后从您的命令行执行该文件。
以下是需要创建您的 VS 解决方案及其项目的命令列表:
-
从您的操作系统控制台导航到您想要创建新解决方案的目录,并执行以下命令以创建解决方案文件:
md UqsAppointmentBooking cd UqsAppointmentBooking dotnet new sln -
执行此操作以创建项目,并注意我们为每个项目使用不同的模板:
dotnet new blazorwasm -n Uqs.AppointmentBooking.Website dotnet new webapi -n Uqs.AppointmentBooking.WebApi dotnet new classlib -n Uqs.AppointmentBooking.Contract dotnet new classlib -n Uqs.AppointmentBooking.Domain dotnet new xunit -n Uqs.AppointmentBooking.Domain.Tests.Unit -
将项目添加到解决方案中:
dotnet sln add Uqs.AppointmentBooking.Website dotnet sln add Uqs.AppointmentBooking.WebApi dotnet sln add Uqs.AppointmentBooking.Contract dotnet sln add Uqs.AppointmentBooking.Domain dotnet sln add Uqs.AppointmentBooking.Domain.Tests.Unit -
现在,让我们设置项目之间的依赖关系:
dotnet add Uqs.AppointmentBooking.Website reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.WebApi reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.Domain reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.WebApi reference Uqs.AppointmentBooking.Domain dotnet add Uqs.AppointmentBooking.Domain.Tests.Unit reference Uqs.AppointmentBooking.Domain
最后一点是向项目添加所需的 NuGet 包。域项目将使用 EF 与 SQL Server 数据库通信。Microsoft.EntityFrameworkCore.SqlServer 包允许所需的库连接到 SQL Server。要将此库添加到 Domain 项目中,请使用以下命令:
dotnet add Uqs.AppointmentBooking.Domain package
Microsoft.EntityFrameworkCore.SqlServer
-
单元测试项目将需要 NSubstitute 进行模拟,因此让我们添加其 NuGet:
dotnet add Uqs.AppointmentBooking.Domain.Tests.Unit package NSubstitute -
我们将使用模拟来测试 EF 的双倍。这个模拟将创建一个内存数据库,这将使我们的测试编写更容易。我们将在本章后面详细讨论这个问题,但现在,让我们添加这个模拟库:
dotnet add Uqs.AppointmentBooking.Domain.Tests.Unit package Microsoft.EntityFrameworkCore.InMemory
为了视觉检查,您可以使用 VS 打开解决方案文件,它应该看起来像这样:

图 9.2 – VS 解决方案资源管理器视图
在这个阶段,您的解决方案结构应该看起来类似。
现在项目结构已经就绪,我们将修改代码。
设置域项目
从 第八章 的域分析,设计预约应用程序,我们已经创建了一个域对象的列表。我将不会再次过目它们;我将在 Domain 项目下的 DomainObjects 中创建并添加它们:

图 9.3 – 添加了领域对象
这些只是没有业务逻辑的数据结构。以下是其中之一,Customer领域对象的源代码:
namespace Uqs.AppointmentBooking.Domain.DomainObjects;
public class Customer
{
public int Id { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
您可以在线查看本章 GitHub 仓库中的其余文件。
接下来是连接本章的重点,EF。
连接 Entity Framework
我们将使用 EF 将每个领域对象存储在具有相同名称但复数的数据库表中,这是 EF 的默认行为。因此,Customer领域对象在数据库中将有一个对应的Customers表。
在本章中,我们不会在 EF 中进行太多自定义,因为我们的目的是专注于 TDD,在这里做的小设置只是琐事,你可以在配套代码中找到它们。
在Domain项目中,我添加了一个名为Database的目录来包含我们的 EF 相关类。我们需要两个类,ApplicationContext类和SeedData类:

图 9.4 – 添加了 EF 文件
在下一节中,我们将讨论它们的作用。
添加上下文类
使用 EF,您添加一个上下文类来引用所有领域对象。我把我上下文类命名为ApplicationContext,并遵循基本的 EF 实践。以下是我的类:
public class ApplicationContext : DbContext
{
public ApplicationContext(
DbContextOptions<ApplicationContext> options) :
base(options){}
public DbSet<Appointment>? Appointments { get; set; }
public DbSet<Customer>? Customers { get; set; }
public DbSet<Employee>? Employees { get; set; }
public DbSet<Service>? Services { get; set; }
public DbSet<Shift>? Shifts { get; set; }
}
这是 EF 的最基本设置,没有任何自定义,每个属性都映射到数据库表名。
从现在开始,我们将使用ApplicationContext来执行对数据库的操作。
让我们继续我们的过程,在 WebApi 中设置 EF。
将 EF 与 WebApi 项目连接起来
WebApi 将 EF 连接到正确的数据库提供程序,在这种情况下是 SQL Server,并在运行时将连接字符串传递给 EF。
因此,第一步是将连接字符串添加到 WebApi 的AppSettings.js中:
"ConnectionStrings": {
"AppointmentBooking": "Data
Source=(localdb)\\ProjectModels;Initial
Catalog=AppointmentBooking;(…)"
},
显然,连接字符串可能根据您的数据库位置和配置而有所不同。
注意
在本章中,我并不关心设置多个环境,但您可能希望为不同的环境创建多个AppSettings,并相应地更改连接字符串。
下一步是将 WebApi 与 EF 连接起来,并为其提供连接字符串。这应该在Program.cs中完成,最好是在第一行var CreateBuilder(args)之后:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ApplicationContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString
("AppointmentBooking")));
这是我们连接 EF 所需的内容。然而,出于开发目的,我们可能需要一些测试数据来填充页面,使其包含一些有意义的数据。我们将在下一步做这件事。
添加种子数据
新创建的数据库具有空表,seed类旨在用示例数据预先填充表。
我不会在这里列出代码,因为这不属于本章的范围,但您可以在Domain项目的Database/SeedData.cs中查看代码。
我们刚刚完成了 WebApi 项目的设置,该项目将被网站使用,所以让我们接下来创建网站。
设置网站项目
本实施的第一阶段包括创建一个网站来访问 API,为用户提供 UI,这是我们在这章之前通过命令行完成的。然而,网站实现超出了本章的范围,也超出了本书的范围,因为它与 TDD 无关,所以我不打算展示代码。
虽然,我们只对一个方面感兴趣——网站对 Web APIs 有什么要求?我们需要理解这一点,以便以 TDD 的方式在WebApis中构建所需的功能。
我们将在本章下一节逐步回答这个问题。
在本节中,我们涵盖了项目的设置和配置方面,我们没有做任何受 TDD 影响的事情。你可能已经注意到,我多次提到了配套源代码;否则,我们就没有地方放置本章的核心,即 TDD 部分,我们将在下一部分进行。
以 TDD 方式实现 WebApis
要构建 WebApi 项目,我们将查看第八章中提到的每个要求,设计一个预约预订应用,并使用 TDD 风格提供满足这些要求的实现。
所有的要求都是以网站及其功能为依据提出的,并且它们并没有规定我们如何构建我们的 API。网站将不得不调用 WebApis 来执行任何业务逻辑,因为它无法访问数据库,并且只处理与 UI 相关的业务逻辑。
本章专门介绍 EF,原因是我们希望你们欣赏模拟,它不像模拟那样受欢迎,它们都属于测试替身家族。它还将是一个典型的.NET 解决方案示例,包括 ASP.NET Core 和关系型数据库实现。
本节中,我们将涵盖在 TDD 模式下工作,同时考虑到我们的持久化提供者 EF。
使用 EF 内存提供者
为了使我们在单元测试系统时生活更轻松,我们希望以优雅的方式抽象数据库。当我说优雅时,我的意思是代码更少,可读性更高。
然而,当我们测试一个具有数据库的系统时,我们面临的挑战是我们不希望在单元测试中击中真实的数据库,因为这会违背单元测试的全部目的,使其成为一种集成或 S 集成测试。相反,我们使用测试替身来抽象它。模拟是一个测试替身,它在单元测试期间用一个更适合测试目的的等效组件替换组件,我们将使用模拟来替换我们的数据库进行单元测试。
EF 有一个提供者可以访问 SQL 服务器,这是我们希望在系统运行期间在生产中使用的东西,但在单元测试中,我们无法这样做。幸运的是,EF 有一个称为内存提供者的东西,它可以在每次单元测试运行期间构建和销毁内存数据库。
在单元测试期间构建和销毁内存数据库的成本与对物理数据库进行相同操作的成本相比非常低,更不用说尝试频繁创建和删除真实数据库的成本和随机错误的可能性了(每个单元测试执行一次)。正如你可能已经猜到的,EF 内存提供程序充当了一个模拟。
在运行时,我们使用 SQL Server 提供程序,而在单元测试时,我们使用内存提供程序,我们通过依赖注入来实现这种切换:

图 9.5 – 与 EF 提供程序相关的运行时间和测试时间
之前的图示说明了在不同项目阶段注入不同的提供程序。单元测试阶段将使用 EF 内存提供程序,而在生产运行阶段,将使用适当的实际生产提供程序,即 EF SQL Server 提供程序。
配置内存提供程序
为了利用内存提供程序的优势,我在单元测试项目中创建了一个名为ApplicationContextFake.cs的文件,以下是代码:
public class ApplicationContextFake : ApplicationContext
{
public ApplicationContextFake() : base(new
DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase(databaseName:
$"AppointmentBookingTest-{Guid.NewGuid()}")
.Options) {}
}
注意,我们正在继承主要的 EF 对象ApplicationContext,并配置了使其成为内存选项。ApplicationContextFake旨在在需要ApplicationContext时注入到我们的单元测试中。
我们通过每次实例化模拟时附加一个 GUID 来创建一个唯一的数据库名称,AppointmentBookingTest-{Guid.NewGuid()}。这样做的原因是我们不希望内存提供程序具有相同的数据库名称,以避免在单元测试调用之间缓存任何数据。
从现在开始,每次我们需要在我们的单元测试中注入ApplicationContext时,我们将注入ApplicationContextFake。
使用构建器模式添加样本测试数据
我们将要实现的每个测试都将有一个状态。例如,我们可能有一个单独的空闲理发师或一组具有不同日程的理发师,如果我们不小心,为每个测试创建样本数据可能会变得混乱。有一种巧妙的方式来组织我们的测试样本数据。
我们可以通过一种称为构建器模式的模式来完成这项工作(不要与 GoF 构建器设计模式混淆)。构建器模式将允许我们以干净和可读的方式混合和匹配样本数据。我已经添加了一个名为ApplicationContextFakeBuilder.cs的文件来包含使用构建器模式的样本状态数据。为了简洁起见,我在这里包含了这个类的一部分,但你可以通过配套源代码查看完整的类:
public class ApplicationContextFakeBuilder
{
private readonly ApplicationContextFake _ctx = new();
private EntityEntry<Employee> _tomEmp;
private EntityEntry<Employee> _janeEmp;
…
private EntityEntry<Customer> _paulCust;
private EntityEntry<Service> _mensCut;
private EntityEntry<Appointment> _aptPaulWithTom;
…
public ApplicationContextFakeBuilder WithSingleEmpTom()
{
_tomEmp = _ctx.Add(new Employee {
Name = "Thomas Fringe" });
return this;
}
…
public ApplicationContextFake Build()
{
_ctx.SaveChanges();
return _ctx;
}
}
这个类将准备内存样本数据。将使用这个类的单元测试将调用它的不同方法来设置正确的数据状态。这个类中有趣的是以下内容:
-
使用
With约定来表示我们正在添加样本数据。你将在后面看到With方法的使用示例。 -
With方法返回this,乍一看可能有点奇怪。这里的想法是实现一种称为链式编写的编码约定,这样您就可以编写如下代码:_ctxBldr.WithSingleService(30).WithSingleEmpTom()。 -
Build()方法将把一切保存到持久化媒体(在这种情况下是内存)并返回上下文。
构建者模式在尝试设置某个组件的状态时被大量使用。您可以自由地查看配套代码以获取完整代码。第六章,《TDD 的 FIRSTHAND 指南》中有一个构建类的示例;您可能想看看它以加深您的理解。
实现第一个故事
我们需求中的第一个故事非常简单。网站将显示我们拥有的所有可用服务。由于网站将通过 RESTful API 调用从 WebApi 请求这些数据,因此领域层将有一个返回此列表的服务。让我们假设这将是我们 UI 的输出:

图 9.6 – 故事 1 需求的一个 UI
UI 层,托管在浏览器中,将需要向 WebApi 发出 RESTful 调用,这可能看起来如下:
GET https://webapidomain/services
这个 UI 将需要一些数据属性,这些属性应由这个 API 返回。因此,获取的 JSON 可以看起来像这样一个数组:
{
"ServiceId": 2,
"Name": "Men - Clipper & Scissor Cut",
"Duration": 30,
"Price": 23.0
}
您可以看到每个部分在页面上的使用位置,但也许ServiceId不是很清楚。它将被用来构造select超链接的 URL。因此,我们现在可以设计一个合约类型来渲染这个 JSON,它可能看起来像这样:
namespace Uqs.AppointmentBooking.Contract;
public record Service(int ServiceId, string Name,
int Duration, float Price);
这个record合约将渲染之前的 JSON 代码。完整的返回数组合约可能看起来像这样:
namespace Uqs.AppointmentBooking.Contract;
public record AvailableServices(Service[] Services);
您可以在页面上找到这些合约类型和所有其他合约,在Contract项目中。
通过 TDD 添加第一个单元测试
沿着 DDD 的思路,我们将有一个名为ServicesService的领域服务,它将处理检索所有可用服务。因此,让我们看看这个服务的结构。我们将在Domain项目下的Services中创建它。以下是代码:
public class ServicesService
{
}
这里没有什么特别之处。我只是帮助 VS 理解,当我输入ServicesService时,它应该引导我到这个类。
注意
我已经手动添加了之前的ServicesService类。一些 TDD 实践者喜欢在编写单元测试时进行代码生成,而不是先编写它。只要您更有效率,任何方法都可以。我选择先创建文件,因为有时 VS 会创建这个文件在我打算放置的不同目录中。
我将创建一个名为ServicesServiceTests的单元测试类,代码如下:
public class ServicesServiceTests : IDisposable
{
private readonly ApplicationContextFakeBuilder _ctxBldr
= new();
private ServicesService? _sut;
public void Dispose()
{
_ctxBldr.Dispose();
}
}
我立即添加了ApplicationContextFakeBuilder,因为我知道我将在单元测试中处理样本数据。
现在,我需要考虑我需要从我的服务中得到什么,并据此构建一个单元测试。最直接的方法是选择最简单的情况。如果没有理发师服务,则不会返回任何服务:
[Fact]
public async Task
GetActiveServices_NoServiceInTheSystem_NoServices()
{
// Arrange
var ctx = _ctxBldr.Build();
_sut = new ServicesService(ctx);
// Act
var actual = await _sut.GetActiveServices();
// Assert
Assert.True(!actual.Any());
}
我在测试中决定将有一个名为GetActiveServices的方法,当调用此方法时,它将返回一个活跃服务的集合。在这个阶段,代码无法编译;因此,不存在该方法。我们已经得到了 TDD 的失败!
现在,我们可以指示 VS 生成这个方法,然后我们可以编写实现:
public class ServicesService
{
private readonly ApplicationContext _context;
public ServicesService(ApplicationContext context)
{
_context = context;
}
public async Task<IEnumerable<Service>>
GetActiveServices()
=> await _context.Services!.ToArrayAsync();
}
这是通过 EF 获取所有可用服务,因为我们没有在样本数据中存储任何服务,所以没有返回任何服务。
如果你再次运行测试,它将通过。这是我们 TDD 的测试通过。由于这是一个简单的实现,不需要重构阶段。恭喜你,你已经完成了你的第一个测试!
注意
这个测试很简单,看起来像是在浪费时间。然而,这是一个有效的测试用例,它还帮助我们创建我们的领域类并注入正确的依赖项。从一个简单的测试开始,有助于稳步推进。
通过 TDD 添加第二个单元测试
我们需要添加的第二个功能是只获取活跃的服务,而不是那些不再活跃的服务,因为理发师不再提供它们。所以,让我们从这个单元测试开始:
[Fact]
public async Task
GetActiveServices_TwoActiveOneInactiveService_TwoServices()
{
// Arrange
var ctx = _ctxBldr
.WithSingleService(true)
.WithSingleService(true)
.WithSingleService(false)
.Build();
_sut = new ServicesService(ctx);
var expected = 2;
// Act
var actual = await _sut.GetActiveServices();
// Assert
Assert.Equal(expected, actual.Count());
}
我们的Arrange将添加三个服务——两个活跃和一个不活跃。看看WithSingleService的代码很有趣:
public ApplicationContextFakeBuilder WithSingleService
(bool isActive)
{
_context.Add(new Service{ IsActive = isActive });
return this;
}
如果我们运行测试,当然会失败,因为我们没有为我们的服务添加任何过滤功能。让我们继续为服务添加过滤功能:
public async Task<IEnumerable<Service>> GetActiveServices()
=> await _context.Services!.Where(x => x.IsActive)
.ToArrayAsync();
我们添加了一个Where LINQ 语句,这将解决问题。再次运行测试,这个测试应该会通过。
这是一个简单的需求。实际上,所有故事都很直接,除了第 5 个故事。我们不会在这里列出其他故事,因为它们很相似,但你可以从配套源代码中找到它们。相反,我们将专注于第 5 个故事,因为它的复杂性符合现实生活中的生产代码,并揭示了 TDD 的主要好处。
实现第 5 个故事(时间管理)
这个故事是关于一个时间管理系统。它试图公平地管理理发师的时间,考虑到休息时间。如果你花点时间思考这个故事,它很复杂,有很多边缘情况。
这个故事揭示了 TDD 的力量,因为它将帮助你找到一个起点,并添加少量增量步骤来构建需求。当你完成时,你会注意到你已经在单元测试中自动记录了故事。
在接下来的章节中,我们将找到一种从更容易实现的情况开始,逐步过渡到更复杂的测试情况的方法。
检查记录
一种温和的开始实现的方法是检查方法的签名,这会让我们思考。
从逻辑上讲,为了确定员工的可用性,我们需要通过使用employeeId和所需的时间长度来知道这位员工是谁。长度可以通过serviceId从服务中获取。方法的一个合理名称可以是GetAvailableSlotsForEmployee。我们的第一个单元测试就是这个:
[Fact]
public async Task
GetAvailableSlotsForEmployee_ServiceIdNoFound_
ArgumentException()
{
// Arrange
var ctx = _contextBuilder
.Build();
_sut = new SlotsService(ctx, _nowService, _settings);
// Act
var exception = await
Assert.ThrowsAsync<ArgumentException>(
() => _sut.GetAvailableSlotsForEmployee(-1));
// Assert
Assert.IsType<ArgumentException>(exception);
}
它无法编译;这是一个失败。因此,在SlotsService中创建方法:
public async Task<Slots> GetAvailableSlotsForEmployee(
int serviceId)
{
var service = await _context.Services!
.SingleOrDefaultAsync(x => x.Id == serviceId);
if (service is null)
{
throw new ArgumentException("Record not found",
nameof(serviceId));
}
return null;
}
现在你已经有了实现,再次运行测试,它们将会通过。你也可以对employeeId做同样的事情,就像我们对serviceId所做的那样。
从最简单的场景开始
让我们从添加最简单的业务逻辑开始。假设系统有一个名叫 Tom 的员工。Tom 在系统中没有可用的班次。此外,系统中只有一个服务:
[Fact]
public async Task GetAvailableSlotsForEmployee_
NoShiftsForTomAndNoAppointmentsInSystem_NoSlots()
{
// Arrange
var appointmentFrom =
new DateTime(2022, 10, 3, 7, 0, 0);
_nowService.Now.Returns(appointmentFrom);
var ctx = _contextBuilder
.WithSingleService(30)
.WithSingleEmployeeTom()
.Build();
_sut = new SlotsService(ctx, _nowService, _settings);
var tom = context.Employees!.Single();
var mensCut30Min = context.Services!.Single();
// Act
var slots = await
_sut.GetAvailableSlotsForEmployee(
mensCut30Min.Id, tom.Id);
// Assert
var times = slots.DaysSlots.SelectMany(x => x.Times);
Assert.Empty(times);
}
这将失败,因为无论输入是什么,该方法都会返回null。我们需要继续向解决方案中添加代码片段。我们可以从以下代码开始:
…
var shifts = _context.Shifts!.Where(
x => x.EmployeeId == employeeId);
if (!shifts.Any())
{
return new Slots(Array.Empty<DaySlots>());
}
return null;
之前的代码正是通过测试所必需的。现在测试是绿色的。
提高场景的复杂性
其余的单元测试遵循略微提高测试场景复杂性的相同方式。以下是一些你可能想要添加的其他场景:
[Theory]
[InlineData(5, 0)]
[InlineData(25, 0)]
[InlineData(30, 1, "2022-10-03 09:00:00")]
[InlineData(35, 2, "2022-10-03 09:00:00",
"2022-10-03 09:05:00")]
public async Task GetAvailableSlotsForEmployee_
OneShiftAndNoExistingAppointments_VaryingSlots(
int serviceDuration, int totalSlots,
params string[] expectedTimes)
{
…
之前的测试实际上是多个测试(因为我们使用了Theory),每个InlineData都提高了复杂性。像往常一样,先做红色,再做绿色,以便在添加另一套测试之前通过:
public async Task GetAvailableSlotsForEmployee_
OneShiftWithVaryingAppointments_VaryingSlots(
string appointmentStartStr, string appointmentEndStr,
int totalSlots, params string[] expectedTimes)
{
…
这也是一个带有多个InlineData的测试。显然,我们无法在这里放入所有代码,所以请查看SlotsServiceTests.cs以获取完整的单元测试。
当你开始添加更多的测试用例时,无论是使用带有InlineData的Theory还是使用Fact,你都会注意到实现中的代码复杂性正在上升。这是完全可以的!但是,你是否觉得可读性正在下降?那么,是时候重构了。
现在你有了单元测试的优势,它们可以保护代码不被破坏。当方法做你想让它做的事情时进行重构是红-绿-重构咒语的一部分。实际上,如果你查看SlotsService.cs,我确实重构了,通过创建多个私有方法来提高可读性。
这个故事很复杂,我必须承认。我本可以选择一个更容易的例子,大家都会很高兴,但现实生活中的代码有起有落,复杂性各异,所以我想要包含一个符合书籍实用主义主题的复杂场景。
在本节之后,你可能会有一些问题。我希望我能在下面回答一些。
回答常见问题
现在我们已经编写了单元测试和相关的实现,让我解释一下这个过程。
这些单元测试足够吗?
这个问题的答案取决于你的目标覆盖率以及你对所有情况都被覆盖的信心。有时,添加更多的单元测试会增加未来的维护开销,所以随着经验的积累,你会找到正确的平衡点。
为什么我们没有对控制器进行单元测试?
控制器不应该包含业务逻辑。我们将所有逻辑推送到服务中,然后测试服务。控制器中剩下的只是将不同类型映射到彼此的最小代码。查看Uqs.AppointmentBooking.WebApi/Controllers中的控制器,以了解我的意思。
单元测试在测试业务逻辑或存在条件和分支的区域方面表现出色。我们选择编码风格中的控制器没有这些。
控制器应该被测试,但通过不同类型的测试。
我们测试系统足够了吗?
不,我们没有!我们完成了单元测试部分。我们没有测试控制器或系统的启动(Program.cs的内容)以及其他一些小的代码。
我们没有通过单元测试测试它们,因为它们不是业务逻辑。然而,它们需要测试,但单元测试不是检查这些区域质量的最佳测试类型。你可以通过其他类型的测试来覆盖这些区域,例如集成测试、S 集成测试和系统测试。
我们省略了一些区域的测试,如何实现高覆盖率?
代码的一些区域没有进行单元测试,例如Program.cs和控制器。如果你目标是实现高代码覆盖率,例如 90%,你可能无法仅通过单元测试实现,因为这里有很多代码,在这一章中。
仅通过单元测试实现覆盖率是不公平的,因为你需要额外的测试类型来实现更多的覆盖率,否则开发者可能会通过添加无意义的测试来提高覆盖率。这些测试弊大于利,因为它们将增加维护负担。
覆盖率计算应包括其他类型的测试,而不仅仅是依赖单元测试。如果是这样,90%是一个现实的目标,并且可以导致高质量的产品。
有时候很难配置覆盖率测量工具来测量多种测试类型的总和,因此在这种情况下,将你的代码覆盖率目标降低到大约 80%左右是有意义的。
摘要
我们通过使用 EF 和 SQL Server 设置系统,然后通过逐步添加单元测试并随着每个额外单元测试的增加复杂性来逐步构建,实现了实现真实故事的实现。
我们看到了一个现实中的模拟测试双例和一个具体的构建器来构建我们的样本数据。
我们不得不选择多个重要场景来鼓励你检查完整的源代码,否则页面将会充满代码。
如果你已经阅读并理解了代码,那么我向你保证,这是复杂性的顶峰,因为其他章节应该更容易阅读和遵循。所以恭喜你,你已经通过了这本书的难点部分!我相信你现在可以继续使用 EF 和关系型数据库开始你的基于 TDD 的项目。
希望这一章能为你开启基于 EF 和 SQL Server 的新项目提供指导。下一章将进行相同的实现,但专注于文档数据库,并且与这一章的模式不同。
第十章:使用存储库和文档数据库构建应用程序
在第八章中,设计预约预订应用程序,我们制定了为名为 Heads Up Barbers 的理发店构建预约预订系统的技术规范和设计决策。本章是第八章的延续,因此我强烈建议您首先熟悉它。
本章将按照 TDD 风格实现需求,并使用存储库模式与Azure Cosmos DB一起使用。实现将适用于其他文档数据库,即NoSQL,如MongoDB、Amazon DynamoDB、GCP Firestore和其他数据库。
如果您是文档数据库的粉丝或者在工作中正在使用它,那么这一章就是为您准备的,而如果您正在使用关系数据库,那么您可能想要跳过这一章,回到上一章,第九章。这两章,第九章和第十章具有相同的结果,但它们使用不同的后端数据库类别。
本章假设您熟悉文档数据库服务以及文档数据库背后的理念,不一定是 Cosmos DB,因为从 TDD 的角度来看,不同数据库产品之间的实现几乎相同。
在本章中,我们将涵盖以下内容:
-
规划代码和项目结构
-
使用 TDD 实现 Web API
-
回答常见问题
到本章结束时,您将体验使用 TDD 和模拟以及文档数据库后端实现端到端应用程序的过程。您还将见证在编写单元测试之前进行的分析过程。
技术要求
本章的代码可以在以下 GitHub 存储库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch10
要运行此项目,您需要安装一个 Cosmos DB 实例。这可以是以下之一:
-
在 Azure 账户下的云中运行的 Azure Cosmos DB
-
Azure Cosmos DB 模拟器,可以在 Windows、Linux 和 macOS 上本地安装,并可以从 Docker 运行
实现不使用任何高级 Cosmos 功能,因此您可以自由使用任何 Cosmos 版本。我已经在 Windows 本地使用 Azure Cosmos DB 模拟器测试了应用程序。您可以在以下链接中了解更多信息:
docs.microsoft.com/en-us/azure/cosmos-db/local-emulator
安装本地模拟器后,您需要获取连接字符串,您可以通过浏览到https://localhost:8081/_explorer/index.xhtml并从主连接字符串字段复制连接字符串来完成此操作:

图 10.1 – 查找 Cosmos DB 连接字符串
要运行项目,你必须将连接字符串设置为 Uqs.AppointmentBooking.WebApi/AppSettings.json 中的特定数据库实例,如下所示:
"ConnectionStrings": {
"AppointmentBooking": "[The primary connection string]"
},
连接字符串指向 LocalMachine 并连接到名为 AppointmentBooking 的数据库。
注意
在本章中,我并不关心设置多个环境,但你可能希望为不同的环境创建多个 AppSettings 并相应地更改连接字符串。
之前的数据库配置步骤是可选的。你可以不使用数据库来实现本章的要求,但你将无法运行项目并在浏览器中与之交互。
规划代码和项目结构
在第八章,设计预约应用,我们规划了我们的领域并分析了我们需要做什么。项目架构将遵循经典的客户端应用(网站)、业务逻辑(Web API)和数据库(Cosmos DB)的三层应用。让我们将其转换为 VS 解决方案和项目。
在本节中,我们将创建解决方案和项目,并连接组件。
分析项目结构
让一组高级开发者就一个项目结构达成一致,最终你可能会得到多个结构!在本节中,我们将讨论一种我多年来开发的项目结构组织方式。
由于我们首先将构建一个用户网站,然后是移动应用(本书未涵盖),因此将业务逻辑隔离到一个可以被网站和移动应用共享的 Web API 项目中是有意义的。因此,我们将基于 Blazor WebAssembly 构建一个名为 Uqs.AppointmentBooking.Website 的网站项目。
领域逻辑将以 API 的形式公开,因此我们将为这个项目创建一个名为 Uqs.AppointmentBooking.WebApi 的 ASP.NET API 项目。
之前两个项目需要在一个称为 Uqs.AppointmentBooking.Contracts 的约定结构中交换数据。此项目将由网站和 Web API 项目引用。
Web API 项目将 Web 请求转换为我们可以用 C# 理解的内容。从技术角度来说,这将管理我们的 HTTP 通信层,采用 RESTful 风格的 API。因此,WebApi 项目将不包含业务逻辑。业务逻辑将在我们的领域项目中。我们将创建一个名为 Uqs.AppointmentBooking.Domain 的领域项目。
你的业务逻辑将存在于两个地方 – UI 和领域层。UI 业务逻辑将管理 UI 功能,如切换下拉菜单、锁定日历日、响应拖放操作以及启用/禁用按钮等。这种逻辑将存在于网站项目中。
重要提示
UI 框架如 Blazor 和 Angular 作为独立应用程序。这些框架通过设计使用名为 模型-视图-视图模型(MVVM)的设计模式,这使得依赖注入和单元测试变得容易。然而,对 UI 特定元素(Blazor 中的 razor 文件)进行单元测试需要更专业的框架,如 bUnit。
编写代码所使用的语言取决于所使用的 UI 框架,例如 Angular、React 和 Blazor。在我们的实现中,UI 层将包含很少的代码,因此我们不会进行任何 UI 单元测试。
复杂的业务逻辑将存在于领域层,我们将按照 TDD 的概念来编写它。因此,我们将创建一个项目来保存我们的领域单元测试,并将其命名为 Uqs.AppointmentBooking.Domain.Tests.Unit。
为了将这些项目置于正确的视角并将它们映射到我们的三层架构,我们可以有以下的图示:

图 10.2 – 项目与应用设计之间的关系
之前的图示显示了每个项目提供的功能,以形成三层应用程序。让我们先创建 VS 解决方案结构。
创建项目和配置依赖项
这是无聊但不可避免的步骤,创建解决方案和项目,然后将它们链接在一起。在下一节中,我们将采用命令行方法而不是用户界面。
注意
我已经将包含所有命令行的文本文件 create-projects.bat 添加到项目源代码控制中,这样你就不必手动编写它们。你可以将此文件复制并粘贴到你的目标目录,然后从你的命令行执行该文件。
以下是将创建你的 VS 解决方案及其项目的命令列表:
-
从你的操作系统控制台,导航到你想要创建新解决方案的目录,并执行以下操作以创建解决方案文件:
md UqsAppointmentBooking cd UqsAppointmentBooking dotnet new sln -
执行此操作以创建项目,并注意我们为每个项目使用了不同的模板:
dotnet new blazorwasm -n Uqs.AppointmentBooking.Website dotnet new webapi -n Uqs.AppointmentBooking.WebApi dotnet new classlib -n Uqs.AppointmentBooking.Contract dotnet new classlib -n Uqs.AppointmentBooking.Domain dotnet new xunit -n Uqs.AppointmentBooking.Domain.Tests.Unit -
将项目添加到解决方案中:
dotnet sln add Uqs.AppointmentBooking.Website dotnet sln add Uqs.AppointmentBooking.WebApi dotnet sln add Uqs.AppointmentBooking.Contract dotnet sln add Uqs.AppointmentBooking.Domain dotnet sln add Uqs.AppointmentBooking.Domain .Tests.Unit -
现在让我们设置项目之间的依赖关系:
dotnet add Uqs.AppointmentBooking.Website reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.WebApi reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.Domain reference Uqs.AppointmentBooking.Contract dotnet add Uqs.AppointmentBooking.WebApi reference Uqs.AppointmentBooking.Domain dotnet add Uqs.AppointmentBooking.Domain.Tests.Unit reference Uqs.AppointmentBooking.Domain
最后一步是向项目添加所需的 NuGet 包。领域项目将使用 Microsoft.Azure.Cosmos 包中的 Cosmos SDK 与 Cosmos DB 进行通信。将此库添加到 Domain 项目中,如下所示:
dotnet add Uqs.AppointmentBooking.Domain package
Microsoft.Azure.Cosmos
-
单元测试项目将需要
NSubstitute进行模拟,因此让我们添加它的 NuGet:dotnet add Uqs.AppointmentBooking.Domain.Tests.Unit package NSubstitute
为了视觉检查,你可以用 VS 打开解决方案文件,它应该看起来像这样:

图 10.3 – VS 解决方案资源管理器视图
在这个阶段,你的解决方案结构应该看起来类似。
现在项目结构已经就绪,我们将修改代码。
设置领域项目
从 第八章 的领域分析,“设计预约应用”,我们已经创建了一个领域对象的列表。我不会再次过目它们;我只会创建并将它们添加到 Domain 项目下的 DomainObjects:

图 10.4 – 添加了领域对象
这些只是没有业务逻辑的数据结构。以下是其中之一,即 Customer 领域对象的源代码:
namespace Uqs.AppointmentBooking.Domain.DomainObjects;
public class Customer : IEntity
{
public string? Id { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
你可以在该章节的 GitHub 存储库中在线查看其余的文件。这是上一个类的接口:
public interface IEntity
{
public string? Id { get; set; }
}
IEntity 是一个接口,确保所有将要持久化到文档容器的领域对象都有一个 Id。
注释
Id 是一个字符串,因为这是文档数据库所期望的,通常情况下,但并非必然,这个字符串是一个 GUID。
我们的文档容器和领域对象之间的关系是什么?
设计你的容器
我假设你已经熟悉文档数据库的基础知识,所以不会过多深入。让我首先定义一下什么是 容器,以便我们在本章中有一个共同的理解。容器是一个存储单元,用于存储类似类型的文档。文档数据库中的容器与关系数据库中的表具有相似的特征。
在设计容器和需要考虑的因素方面,有许多学校和观点,但本书的重点是 TDD,所以我们将保持简明扼要。显然,在 DDD 中设计,尽管有一些指导原则,但仍然是一个主观的过程。感觉我们的聚合路由 Service、Employee、Customer 和 Appointment 是成为容器的直接竞争者,所以我们将它们设置为容器。
接下来,我们需要一种方式让我们的领域服务与数据库交互。这是通过存储库模式来实现的。
探索存储库模式
我们现在已经定义了容器。我们只需要这些容器交互的机制。DDD 使用存储库模式来实现这一目的。让我们来了解一下模式的作用以及它在我们的应用程序中的位置。
理解存储库模式
存储库层是知道如何与数据库交互的代码,无论底层是什么类型的数据库(无论是 Cosmos、SQL Server、文本文件还是其他),文档、关系型或其他。这一层旨在隔离领域层,使其不必要了解数据库的具体细节。相反,领域服务将只关注要持久化的数据,而不是它们将如何被持久化。
下一个图显示了存储库作为领域层的底层:

图 10.5 – DDD 中的存储库
从图中可以看出,任何要持久化到数据库的域对象都会通过存储库层。
创建存储库的常见做法是每个容器创建一个存储库。因此,对于我们的应用程序,我们将有四个存储库:
-
ServiceRepository -
CustomerRepository -
AppointmentRepository -
EmployeeRepository
由于我们必须要对实现进行单元测试,因此我们的存储库需要是单元测试就绪的。
存储库和单元测试
我们突然在关于 TDD 的章节中开始讨论存储库。原因是当你想到单元测试时,首先想到的是依赖项以及如何隔离数据库。
存储库是解决这个问题的答案,因为它们应该提供将数据库转换为可注入依赖项所需的抽象。你将在本章后面清楚地看到这一点。
注意
如果你使用过像 Entity Framework 或 NHibernate 这样的对象关系映射器(ORM)与关系数据库一起工作,那么你可能没有直接使用存储库模式,因为 ORM 框架消除了使用它的需要。
你会看到我们的存储库将具有接口,这将使它们能够进行注入就绪。理论就到这里,让我给你展示一些代码。
实现存储库模式
现在你已经对存储库有了概念,让我们从一个例子开始。所需的存储库之一是 ServiceRepository,它将与服务存储库交互:

图 10.6 – 服务存储库
ServiceRepository 类包含添加服务、删除服务和搜索特定服务等方法。让我们从 GetActiveService 存储库类中随机选择一个方法:
public async Task<Service?> GetActiveService(string id)
{
var queryDefinition = new QueryDefinition(
"SELECT * FROM c WHERE c.id = @id AND c.isActive = true")
.WithParameter("@id", id);
return (await GetItemsAsync(queryDefinition))
.SingleOrDefault();
}
上面的方法使用针对 Cosmos DB 的特定代码来访问数据库,并通过其 ID 返回一个服务。
注意到存储库正在实现 IServiceRepository 接口,这在单元测试期间会很有用。
存储库与容器交互的方式有很多重复。它存储文档,读取文档,删除文档,搜索文档,等等。因此,我们可以创建一个小的框架来嵌入这些行为,并减少重复的代码。
利用存储库模式框架
每次我看到一个项目访问文档数据库时,我都会注意到开发者提前创建了一个小的存储库框架来简化代码。以下是我创建的一个用于访问 Cosmos DB 的框架的摘录,即 CosmosRepository<T> 类,它是所有存储库的基类:
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Options;
using System.Net;
namespace Uqs.AppointmentBooking.Domain.Repository;
public abstract class CosmosRepository<T> :
ICosmosRepository<T> where T : IEntity
{
protected CosmosClient CosmosClient { get; }
protected Database Database { get; }
protected Container Container { get; }
public CosmosRepository(string containerId,
CosmosClient cosmosClient,
IOptions<ApplicationSettings> settings)
{
CosmosClient = cosmosClient;
Database = cosmosClient.GetDatabase(
settings.Value.DatabaseId);
Container = Database.GetContainer(containerId);
}
public Task AddItemAsync(T item)
{
return Container.CreateItemAsync(item,
new PartitionKey(item.Id));
}
…
上面的代码为存储库提供了与数据库交互所需的基本方法,例如 AddItemAsync。
详细介绍 Cosmos DB 的具体细节超出了本书的范围,但代码易于阅读,你可以在源代码的 Uqs.AppointmentBooking.Domain/Repository 目录中找到完整的实现。
现在我们已经创建了仓库,为了开发目的,我们可能需要一些测试数据来填充页面,使其包含一些有意义的数据。我们将在下一步做这件事。
添加种子数据
新创建的数据库具有空容器,seed 类旨在预先填充表中的示例数据。
我不会在这里列出代码,因为它超出了本章的范围,但你可以在 Database/SeedData.cs 中的 Domain 项目的代码中查看。
我们刚刚完成了 WebApi 项目的设置,该项目将被网站使用,所以让我们创建网站。
设置网站项目
本实施的第一阶段包括创建一个网站来访问 API,为用户提供 UI,这是我们之前在本章中通过命令行完成的。然而,网站实现超出了本章的范围,也超出了本书的范围,因为它与 TDD 无关,所以我不打算展示代码。
尽管如此,我们感兴趣的是一方面——网站 需要什么 WebApis?我们需要理解这一点,以便以 TDD 的方式在 WebApis 中构建所需的功能。
我们将在本章的下一节逐步回答这个问题。
在本节中,我们完成了项目的设置和配置方面,我们没有做任何受 TDD 影响的事情。你可能已经注意到,我多次提到了配套源代码,因为我想要保持对下一节的关注,同时仍然提供源代码。
使用 TDD 实现 WebApis
为了构建 WebApi 项目,我们将查看来自 第八章,设计预约预订应用 的每个需求,并使用 TDD 风格提供满足这些需求的实现。
需求都是用 网站 和其功能来表述的,并没有规定如何构建我们的 API。网站 将必须调用 WebApis 来执行任何业务逻辑,因为它无法访问数据库,并且只处理与 UI 相关的业务逻辑。
在本节中,我们将通过 TDD 模式进行工作,考虑到我们的持久化提供者,即仓库。
实现第一个故事
我们需求中的第一个故事非常简单。网站将展示我们拥有的所有可用服务。由于网站将通过 RESTful API 调用从 WebApi 请求这些数据,因此域层将有一个服务来返回这个列表。如果网站要显示这些内容,让我们进一步挖掘:

图 10.7 – 故事 1 的需求 UI
它将需要向 WebApi 发出 RESTful 调用,这可能看起来如下所示:
GET https://webapidomain/services
这个 UI 将需要一些应该由这个 API 返回的数据属性。所以,获取的 JSON 可以看起来像这样一个数组:
{
"ServiceId": "e4c9d508-89d7-49cd-86c2-835cde94472a",
"Name": "Men - Clipper & Scissor Cut",
"Duration": 30,
"Price": 23.0
}
你可以在页面上看到每个部分的使用情况,但也许ServiceId不是很清楚。它将被用来构造Select超链接的 URL。因此,我们现在可以设计一个将渲染此 JSON 的合约类型,它可能看起来像这样:
namespace Uqs.AppointmentBooking.Contract;
public record Service(string ServiceId, string Name,
int Duration, float Price);
这个record合约将渲染之前的 JSON 代码,完整的返回数组合约可能看起来像这样:
namespace Uqs.AppointmentBooking.Contract;
public record AvailableServices(Service[] Services);
你可以在Contract项目中找到这些合约类型和所有其他合约。
通过 TDD 添加第一个单元测试
沿着 DDD 的思路,我们将有一个名为ServicesService的领域服务,它将处理检索所有可用服务。所以,让我们看看这个服务的结构。我们将在Services下的Domain项目中创建它。以下是代码:
public class ServicesService
{
}
这里没有什么特别的。我只是帮助 VS 理解,当我输入ServicesService时,它会引导我到这个类。
注意
我已经手动添加了之前的ServicesService类。一些 TDD 实践者喜欢在编写单元测试时生成这个文件,而不是先编写它。只要你能更高效,任何方法都可以。我选择先创建文件,因为有时候 VS 会创建这个文件在不同的目录下,而不是我想要的目录。
我将创建我的单元测试类,命名为ServicesServiceTests,以下代码:
public class ServicesServiceTests
{
private readonly IServiceRepository _serviceRepository
= Substitute.For<IServiceRepository>();
private ServicesService? _sut;
}
我立即添加了IServiceRepository,因为我知道我将在单元测试中处理数据库,这个接口将是我模拟的依赖。
现在,我需要考虑我需要从我的服务中获取什么,并据此构建一个单元测试。最直接的方法是从最简单的情况开始。如果我们没有理发服务,则不会返回任何服务:
[Fact]
public async Task
GetActiveServices_NoServiceInTheSystem_NoServices()
{
// Arrange
_sut = new ServicesService(_serviceRepository);
// Act
var actual = await _sut.GetActiveServices();
// Assert
Assert.True(!actual.Any());
}
我在测试中决定将有一个名为GetActiveServices的方法,当这个方法被调用时,它将返回一个活动服务的集合。在这个阶段,代码无法编译;因此,不存在该方法。我们已经得到了我们的 TDD 失败!
现在,我们可以指示 VS 生成这个方法,然后我们可以编写实现:
public class ServicesService
{
private readonly IServiceRepository _serviceRepository;
public ServicesService(
IServiceRepository serviceRepository)
{
_serviceRepository = serviceRepository;
}
public async Task<IEnumerable<Service>>
GetActiveServices() =>
await _serviceRepository.GetActiveServices();
}
这是通过仓库获取所有可用服务,由于仓库没有模拟返回任何服务,所以会返回一个空集合。
如果你再次运行测试,它将通过。这是我们 TDD 测试通过。由于这是一个简单的实现,不需要重构阶段,恭喜你,你已经完成了第一个测试!
注意
这个测试很简单,看起来像是在浪费时间。然而,这是一个有效的测试用例,它还帮助我们创建领域类并注入正确的依赖。从一个简单的测试开始可以帮助我们稳步前进。
通过 TDD 添加第二个单元测试
我们需要添加的第二个功能是获取活动服务的功能。所以,让我们从这个单元测试开始:
[Fact]
public async Task
GetActiveServices_TwoActiveServices_TwoServices()
{
// Arrange
_serviceRepository.GetActiveServices()
.Returns(new Service[] {
new Service{IsActive = true},
new Service{IsActive = true},
});
_sut = new ServicesService(_serviceRepository);
var expected = 2;
// Act
var actual = await _sut.GetActiveServices();
// Assert
Assert.Equal(expected, actual.Count());
}
这里有趣的是我们模拟GetActiveServices存储库方法的方式。当服务调用它时,该方法被模拟以返回一个Service数组。这就是我们用数据库替换了相关存储库的方式。
如果您运行这个程序,它应该一次通过而不会失败,所以它不会先失败再通过。这只是一个偶然。在这种情况下,我会调试我的代码,看看为什么单元测试在没有我实现代码的情况下通过了,很明显,第一个单元测试的实现代码已经足够覆盖第二个场景。
这是一个简单的要求。实际上,除了第五个故事之外,所有故事都很直接。我们不会在这里列出其他故事,因为它们很相似,但您可以在配套源代码中找到它们。相反,我们将专注于第五个故事,因为它的复杂性符合现实生活中的生产代码,并且会揭示 TDD 的主要好处。
实现第五个故事(时间管理)
这个故事是关于一个时间管理系统。它试图公平地管理理发师的时间,考虑到休息时间。如果您花点时间思考这个故事,它很复杂,有很多边缘情况。
这个故事揭示了 TDD 的力量,因为它将帮助您找到一个起点,并添加少量增量步骤来构建需求。当您完成时,您会注意到您已经自动在单元测试中记录了故事。
在接下来的章节中,我们将找到一种方法,从更容易实现的情况开始,逐步过渡到更复杂的测试场景。
检查记录
一种温和的开始实现的方法是检查参数,这样我们会考虑方法的签名。
从逻辑上讲,为了确定员工的可用性,我们需要通过employeeId和所需时间的长度来知道这位员工是谁。长度可以通过serviceId从服务中获取。这个方法的一个合理的名字可以是GetAvailableSlotsForEmployee。我们的第一个单元测试就是这个:
[Fact]
public async Task
GetAvailableSlotsForEmployee_ServiceIdNoFound_
ArgumentException()
{
// Arrange
// Act
var exception = await
Assert.ThrowsAsync<ArgumentException>(() =>
_sut.GetAvailableSlotsForEmployee("AServiceId"));
// Assert
Assert.IsType<ArgumentException>(exception);
}
它无法编译;这是一个失败。现在在SlotsService中创建方法:
public async Task<Slots> GetAvailableSlotsForEmployee(
string serviceId)
{
var service = await
_serviceRepository.GetItemAsync(serviceId);
if (service is null)
{
throw new ArgumentException("Record not found",
nameof(serviceId));
}
return null;
}
现在您已经有了实现,再次运行测试,它们将通过。您可以为employeeId做同样的事情,并遵循我们对serviceId所做的一切。
从最简单的情况开始
让我们添加最简单的可能业务逻辑来开始。让我们假设系统有一个名为 Tom 的员工。Tom 在系统中没有可用的班次。此外,系统只有一个服务:
[Fact]
public async Task GetAvailableSlotsForEmployee_
NoShiftsForTomAndNoAppointmentsInSystem_NoSlots()
{
// Arrange
var appointmentFrom = new DateTime(
2022, 10, 3, 7, 0, 0);
_nowService.Now.Returns(appointmentFrom);
var tom = new Employee { Id = "Tom", Name =
"Thomas Fringe", Shifts = Array.Empty<Shift>() };
var mensCut30Min = new Service { Id = "MensCut30Min",
AppointmentTimeSpanInMin = 30 };
_serviceRepository.GetItemAsync(Arg.Any<string>())
.Returns(Task.FromResult((Service?)mensCut30Min));
_employeeRepository.GetItemAsync(Arg.Any<string>())
.Returns(Task.FromResult((Employee?)tom));
// Act
var slots = await
_sut.GetAvailableSlotsForEmployee(mensCut30Min.Id,
tom.Id);
// Assert
var times = slots.DaysSlots.SelectMany(x => x.Times);
Assert.Empty(times);
}
您可以通过模拟来查看如何填充存储库。这就是我们设置数据库和进行依赖注入的方式。我们之所以能够这样做,是因为SlotsService通过存储库访问数据库,如果存储库被模拟,那么我们就替换了我们的数据库。
注意
用模拟仓库替换数据库是一个热门的面试问题,其内容类似于如何在每次单元测试后清理数据库? 这是一个陷阱问题,因为在单元测试期间你并不与数据库交互,而是模拟你的仓库。这个问题有多种变体。
这将失败,因为我们无论输入什么,方法都会返回null。我们需要继续添加代码片段来完善解决方案。我们可以从以下代码开始:
…
if (!employee.Shifts.Any())
{
return new Slots(Array.Empty<DaySlots>());
}
return null;
之前的代码正是通过测试所必需的。现在测试是绿色的。
提高场景的复杂性
其余的单元测试以相同的方式略微提高测试场景的复杂性。以下是一些你可能想要添加的场景:
[Theory]
[InlineData(5, 0)]
[InlineData(25, 0)]
[InlineData(30, 1, "2022-10-03 09:00:00")]
[InlineData(35, 2, "2022-10-03 09:00:00",
"2022-10-03 09:05:00")]
public async Task GetAvailableSlotsForEmployee_
OneShiftAndNoExistingAppointments_VaryingSlots(
int serviceDuration, int totalSlots,
params string[] expectedTimes)
{
…
之前的测试实际上是多个测试(因为我们使用了Theory),每个InlineData都提高了复杂性。像往常一样,先做红色测试,再做绿色测试,以便在添加另一套测试之前通过:
public async Task GetAvailableSlotsForEmployee_
OneShiftWithVaryingAppointments_VaryingSlots(
string appointmentStartStr, string appointmentEndStr,
int totalSlots, params string[] expectedTimes)
{
…
这也是一个包含多个InlineData的测试。显然,我们无法在这里放入所有代码,所以请查看SlotsServiceTests.cs以获取完整的单元测试。
当你开始添加更多的测试用例时,无论是使用Theory和InlineData,还是使用Fact,你都会注意到实现中的代码复杂性正在增加。这是正常的!你感觉可读性变差了吗?那么是时候重构了。
现在你有了单元测试保护代码不被破坏的优势。当方法做你想要它做的事情时进行重构是红-绿-重构格言的一部分。实际上,如果你查看SlotsService.cs,我确实重构了,通过创建多个私有方法来提高可读性。
这个故事很复杂,我承认。我本可以选择一个更容易的例子,大家都会很高兴,但现实生活中的代码有起有落,复杂性各异,所以我想要包含一个复杂的场景,遵循书籍的实用主义主题。
在本节之后,你可能会有一些问题。我希望我能在下面回答一些。
回答常见问题
现在我们已经编写了单元测试和相关的实现,让我解释一下这个过程。
这些单元测试足够了吗?
这个问题的答案取决于你的目标覆盖率以及你对所有情况都被覆盖的信心。有时,添加更多的单元测试会增加未来的维护开销,所以随着经验的积累,你会找到正确的平衡点。
为什么我们没有对控制器进行单元测试?
控制器不应该包含业务逻辑。我们将所有逻辑推送到服务中,然后测试服务。控制器中剩下的只是将不同类型映射到彼此的最小代码。查看Uqs.AppointmentBooking.WebApi/Controllers中的控制器,看看我的意思。
单元测试在测试业务逻辑或存在条件和分支的区域方面表现卓越。我们选择的编码风格中的控制器没有这些。
控制器应该被测试,但通过不同类型的测试。
为什么我们没有对代码库的实现进行单元测试?
代码库包含针对 Cosmos DB 的特定代码,其中包含最小或没有业务逻辑。那里的代码直接与 SDK 交互,测试它并不能证明任何问题,因为你将通过测试替身对框架的行为做出假设。
有时一个代码库包含一些业务逻辑,例如 ServiceRepository 仅选择活动服务,而不是所有服务。这个逻辑仍然很难测试,因为它嵌入在类似 SQL 的语法中,这使得单元测试变得困难。
相反,测试你的代码库以负面的方式扩大了单元测试的范围,这使得你的代码更加脆弱。
一些开发者仍然为了代码覆盖率的目的对他们的代码库进行单元测试,但这里的错误是代码覆盖率是所有类型测试的组合,而不仅仅是单元测试。你的代码库应该由不同类型的测试,如 S 集成测试来覆盖。
我们是否测试了系统足够?
不,我们没有!我们完成了单元测试部分。我们没有测试控制器或系统的启动(Program.cs 的内容)以及其他一些小的代码片段。
我们没有通过单元测试测试它们,因为它们不是业务逻辑。然而,它们需要测试,但单元测试并不是检查这些区域质量的最佳测试类型。你可以通过其他类型的测试来覆盖这些区域,例如集成测试、S 集成测试和系统测试,正如我们在 第四章,使用测试替身的真实单元测试 中讨论的那样。
我们省略了一些区域的测试,我们如何实现高覆盖率?
代码的一些区域没有进行单元测试,例如 Program.cs 和控制器。如果你目标是高代码覆盖率,例如 90%,你可能无法仅通过单元测试实现,因为这里有很多代码。
仅通过单元测试实现覆盖率是不公平的,否则开发者会通过添加无意义的测试来提高覆盖率进行作弊。这些测试弊大于利,因为它们将产生维护负担。
覆盖率计算应包括其他类型的测试,而不仅仅是依赖单元测试。如果是这样,90% 是一个现实的目标,并且可以导致高质量的产品。
有时很难配置覆盖率测量工具来测量多种测试类型的总和,因此在这种情况下,将你的编码覆盖率目标降低到大约 80% 是有意义的。因为并非所有测试都在本地运行,本地测试覆盖率工具(例如前面讨论过的 Fine Code Coverage),只能计算本地执行的测试覆盖率。
所以简短的回答是,你的覆盖率应该包括所有测试类型,这需要一些努力。或者你可以将覆盖率降低到仅单元测试,并追求较低的覆盖率。
概述
我们已经看到了通过设置具有存储库和 Cosmos DB 的系统来实现现实故事的实施,然后通过逐步添加单元测试并随着每个额外单元测试的增加复杂性来逐步构建它。
我们不得不选择多个重要场景来鼓励你检查完整的源代码。否则,页面将被代码填满。
如果你已经阅读并理解了代码,那么我向你保证,这已经是复杂性的顶峰,因为其他章节应该更容易阅读和跟随。所以恭喜你,你已经通过了这本书的难点部分!我相信你现在可以继续前进,并开始使用文档数据库进行基于 TDD 的项目。
本章以基于 TDD 的现实项目实施结束。希望通过理解这本书的这一部分,你将能够使用关系数据库或文档数据库编写你的基于 TDD 的项目。
书的下一部分将介绍如何将单元测试引入你的项目和组织,处理现有的遗留代码,并构建一个持续集成系统。我称之为有趣的部分,在那里你将你的 TDD 知识扩展。
第三部分:将 TDD 应用于你的项目
现在我们知道了如何使用 TDD 构建应用程序,我们想要迈出下一步。在这一部分,我们将介绍如何将单元测试与持续集成相结合,如何处理遗留项目,以及如何在你的组织中实施 TDD。以下章节包含在这一部分:
-
第十一章, 使用 GitHub Actions 实现持续集成
-
第十二章, 处理遗留项目
-
第十三章, 实施 TDD 的复杂性
第十一章:使用 GitHub Actions 实现持续集成
你编写了单元测试和其他类型的测试,并且对你的代码覆盖率和质量感到满意。到目前为止一切顺利,但是谁会确保每次代码更改时都会运行这些测试呢?是推送新代码的开发者吗?如果他们忘记了怎么办?如果源控制中存在可能导致测试失败的合并问题怎么办?谁来检查?
你已经找到了答案。这就是你应该实施的持续集成(CI)系统。CI 是单元测试的自然伴侣,你很少能在现代项目中找到没有 CI 系统的情况。
在本章中,我们将涵盖以下内容:
-
持续集成的介绍
-
使用 GitHub Actions 实现 CI 流程
到本章结束时,你将能够使用 GitHub Actions 实现一个端到端的 CI 流程。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
持续集成介绍
CI(持续集成)这个术语背后的想法是,新代码持续与现有代码集成,这导致了一个可以随时(或至少是意图上)发货到生产的系统。
从软件开发到生产的路线被称为发布管道,代码在此通过多个过程到达生产,例如编译代码、在开发环境中部署二进制文件、允许质量保证人员将代码拉取到特定环境,以及其他操作。CI 是发布管道的一个组成部分。
CI 系统需要一个主机,以便它可以在代码上执行各种操作。主机是服务器和操作系统的组合:


这里有一些本地 CI 服务器的例子:
-
Cruise Control
-
Team City
-
团队基础服务器(TFS)
-
Jenkins
-
Octopus Deploy
你还将能够找到之前提到的系统的 SaaS 解决方案。然而,今天,本地的云解决方案更为流行,例如以下这些:
-
GitHub Actions
-
Azure DevOps
-
AWS CodePipeline
-
Octopus Cloud
-
GitLab CI/CD
这些系统的概念是相同的,当你学习其中一个时,你可以轻松地学习另一个。现在,让我们看看 CI 系统是如何工作的。
CI 工作流程
CI 系统通过执行一系列操作将工作流程应用于你的代码。这些操作是可配置的,并且可能因项目需求而异。这是一个 CI 系统的通用工作流程:

图 11.2 – CI 工作流程
当开发者将新代码推送到源代码控制时,CI 系统通常会监听这个事件。它会复制代码并尝试以类似于您在机器上编译的方式编译它。然后,它尝试发现您测试项目中的所有单元测试并执行它们。
CI 系统高度可定制,因此开发者可能会添加执行多种类型的测试和其他维护步骤。
如果任何步骤失败,CI 系统将放弃构建并将其标记为失败,并通过预配置的方式通知开发者,例如通过电子邮件。
如果所有步骤都通过,那么一个持续部署(CD)系统就可以插入到这个过程的末端。CD 系统会根据您的特定偏好将构建好的代码(二进制文件)部署到服务器上。因此,您通常会同时听到CI/CD这两个术语。
接下来,我们将讨论在软件工程过程中持续集成(CI)的重要性。
CI 系统的优势
CI 系统通常从敏捷过程的冲刺零阶段开始插入。它们从第一天开始就有很好的理由。显然,构建 CI 系统管道需要时间和精力,因此应该有对额外努力的正当理由。在这里,我们将看到 CI 系统的重要性和好处。
代码始终处于编译状态
在团队项目中,您有多少次从源代码控制中拉取最新代码,却发现它无法编译?CI 系统确保代码始终可以编译,最佳实践是在 CI 标记之前的推送为失败时不要拉取新代码,因为除非您自己修复代码或等待同事修复,否则您无法继续工作。
当然,如果您打算修复它,您将不得不拉取损坏的构建,但 CI 系统会给出更早的指示。
通常,源代码控制中的代码损坏发生在开发者没有从源代码控制中拉取最新版本,而在推送代码之前编译和执行测试。
单元测试始终通过
开发者可能会忘记在推送代码之前在自己的机器上执行单元测试,但 CI 系统不会忘记!
根据本书之前的讨论,单元测试应该是高性能的,CI 系统应该不会花费太多时间运行所有单元测试,以便向团队反馈构建是安全的,并且已经准备好让他们拉取新代码。
我特别提到了单元测试,因为它们可以提供快速的反馈。您可能还有其他类型的测试,这些测试可能需要时间来执行,您可能决定是否要在每次推送时执行它们或在一天中的特定时间执行。其他测试通常较慢,需要几分钟才能执行,您可能希望并行运行它们,但不要在它们完成之前阻止反馈,这可能需要 10 分钟或一个小时。
为 CD 准备就绪的代码编译
如果构建成功编译并通过了测试,那么如果这是你软件工程过程的一部分,或者你有二进制文件准备通过 CD 流程部署到你的环境中,那么它就准备好进行手动测试了。
CD 流程将 CI 的输出部署到配置的位置,如你的开发环境、UAT 和生产环境。
CI 在今天的软件工程过程中不再是可选的,正如你可以从其好处中看到的那样。没有理由不实施它。它既便宜又容易实现,正如我们将在使用 GitHub Actions 时看到的那样。
使用 GitHub Actions 实现 CI 流程
初始时,我在为本书的章节设计指南时,计划使用 Azure DevOps 提供一个示例实现,因为它有一个流行的 CI 系统。然而,GitHub Actions 迅速崛起,并很快成为开发者配置 CI 系统的首选,所以我改变了主意,打算使用 GitHub Actions。
GitHub Actions 可以处理多个编程栈;其中之一是 .NET Core,这是我们本章关注的重点。
显然,你需要一个 GitHub 账户来使用 GitHub Actions,而且你会很高兴地知道免费层每月提供 2,000 分钟的运行时间,这对于一个小型独立项目来说应该足够了。
接下来,我们将使用 GitHub Actions 作为第十章中项目的 CI 系统,“使用存储库和 Document DB 构建应用程序”你不需要阅读该章节,我们只是需要一个具有项目和针对其的单元测试的解决方案,以便我们可以展示 Actions 的工作原理,而第十章就有这样的解决方案。
在 GitHub 存储库中创建一个示例项目
要跟随操作,你需要有一个拥有 GitHub 存储库的 GitHub 账户,该存储库托管着一个 .NET 项目。如果你没有,那么你可以创建一个免费的 GitHub 账户。
你需要在你的存储库中拥有第十章的代码,所以最快的方法是访问这本书的 GitHub 页面并点击Fork | 创建新的分支:

图 11.3 – 创建新的分支
或者,访问存储库 URL 并在末尾添加 /fork,如下所示:github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/fork。然后点击创建分支:

图 11.4 – 填写表单并点击创建分支
分支会将存储库的内容复制到一个新创建的属于你的存储库中,这样你就可以在不影响原始存储库的情况下玩弄代码。
虽然我们已经复制了所有代码,但我们只对解决方案中较早的章节 (第十章) 感兴趣,位于以下位置:
/ch10/UqsAppointmentBooking/UqsAppointmentBooking.sln
现在我们有了相同的代码,我们可以为这个项目创建 GitHub Actions CI。
创建工作流程
首先,要为 GitHub Actions 编写任何配置,您必须熟悉 YAML。YAML 是一种文件格式,是 JSON 的替代品,旨在提高人类可读性。随着我们的进行,您将看到 YAML 的示例。
让我们使用 GitHub Actions 向导为 第十章 项目创建工作流程。从您的 GitHub 仓库中选择 操作 | 新建工作流程:

图 11.5 – 创建新的工作流程
GitHub Actions 根据您的仓库内容返回一个建议列表:

图 11.6 – GitHub Actions 工作流程模板的建议列表
我们的代码是 .NET Core,因此第一个建议是合适的;让我们点击 配置。我们将获得以下页面:

图 11.7 – 创建工作流程
注意,GitHub 已经为您的工作流程配置建议了一个文件位置:
/.github/workflows/dotnet.yml
GitHub Actions 位于 workflows 目录中。它还建议以下 YAML 代码作为开始:
name: .NET
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
此工作流程称为 .NET,当有人向主分支推送代码或向主分支提出 pull 请求时将触发。
CI 系统使用 GitHub Actions 可用的最新版本的 Ubuntu Linux,撰写本文时是 Ubuntu 20.04。操作系统将在应用各种操作时托管构建。Linux 通常默认选择,因为它比 Windows 更高效且成本更低,并且显然支持 .NET Core。
然后步骤的执行从以下内容开始:
-
actions/checkout@v3:此操作检出您的仓库,以便您的流程可以访问它。这是请求此操作的版本 3。 -
actions/setup-dotnet@v2:使用此库的版本 2 获取 .NET SDK,并指定 .NET Core 6 作为 .NET 版本。这允许我们在之后使用 .NET CLI。 -
dotnet restore:这是一个标准的 .NET 命令行界面(CLI)命令,用于还原 NuGet 包。 -
dotnet build:编译解决方案。 -
dotnet test:执行解决方案中的所有测试项目。
步骤 1 和 步骤 2 准备主机操作系统上的工作空间,以便能够以与在本地机器上执行相同的方式执行 .NET CLI 命令。如您所发现的,整个文本都使用了 YAML 语法。
您可以继续并点击 开始提交 按钮:

图 11.8 – 开始提交
此后,点击 操作 选项卡以查看 GitHub Actions 将如何执行这些命令:

图 11.9 – 失败的构建
正如您所注意到的,红色标志表明构建已失败。您可以点击失败的构建以获取更多信息:

图 11.10 – 构建失败描述
您可以看到为什么这失败了:它无法找到解决方案文件以恢复依赖项。这是一个预期的错误,因为我们的第十章解决方案文件位于/ch10/UqsAppointmentBooking目录中,而不是在/(根目录),因此我们需要修改 YAML 文件以反映这一点。
从源代码控制中拉取最新版本,您将注意到出现了一个名为workflows的新目录(/.github/workflows),在这个目录中,您可以找到我们刚刚创建的文件:dotnet.yml。
您可以使用任何纯文本编辑器编辑此 YAML 文件。我使用Visual Studio Code进行编辑。我们需要编辑此文件以指示 Actions 在哪里找到解决方案文件:
…
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./ch10/UqsAppointmentBooking
steps:
…
我已经修改了前面的 YAML 文件以包含解决方案文件的位置。如果您将此文件推送到 GitHub,这将触发构建。您可以在操作选项卡中查看结果:

图 11.11 – 构建通过
通过的构建意味着我们在 YAML 文件中指定的所有步骤都已通过。您可以点击通过的构建来查看每个步骤的执行情况,并确保您的步骤准确通过,而不是偶然通过。
我们已经在 GitHub Actions 上为我们的项目创建了一个 CI 系统。现在,每当团队成员修改代码时,CI 工作流程就会启动。
让我们更关注一下测试部分。
CI 和测试
YAML 步骤中的最后一行是为了触发解决方案中所有可用的测试:
- name: Test
run: dotnet test --no-build --verbosity normal
这将针对您解决方案中的所有测试,这可能是一系列单元测试、集成测试、集成测试和系统测试的组合。根据您的项目内容,执行所有测试可能并不理想。这将延迟最近提交的反馈,并阻止其他团队成员验证构建是否安全使用。
因此,您可能希望将工作流程限制为仅单元测试。您可以修改运行命令如下:
dotnet test --filter FullyQualifiedName~Tests.Unit
--no-build --verbosity normal
这将针对所有在其命名空间中包含Tests.Unit的测试。因此,您不必执行所有测试,并且可以快速获得反馈。
对于其他测试,您可以编写另一个包含新工作流程的 YAML 文件,并将其安排在不同的事件上运行,例如每天多次。
让我们看看当测试失败时会发生什么。
模拟失败的测试
假设一位同事在推送到源代码控制之前忘记运行单元测试,天哪。我们可以通过将第十章**的 SlotService 从 || 改为 && 并将代码推送到源代码控制来模拟这种情况:

图 11.12 – 在代码中打破逻辑以触发失败的测试
此更改将使解决方案中的现有单元测试失败,并且通常在推送到源控制之前被捕获,但如果推送,CI 系统将报告以下内容:

图 11.13 – 源控制中的失败测试
上一屏将显示多个失败点,如果您向下滚动,它将显示更多关于失败的测试的描述,包括预期的结果和实际产生的结果:

图 11.14 – 失败测试描述
您可以通过阅读描述来得出什么出了问题。显然,我们知道这里出了什么问题,因为我们故意破坏了代码。在其他情况下,我们可以从 GitHub Actions 上的错误描述中了解出了什么问题,或者我们应该能够在本地机器上再次运行单元测试,并试图找出情况。
您已经看到了一个使用工作流程的实现。接下来,让我们了解其背后的概念。
理解工作流程
GitHub Actions 中的模块是一个工作流程,而工作流程看起来是这样的:

图 11.15 – 工作流程
工作流程位于您存储库的 /.github/workflows 目录中的 YAML 文件中。每个工作流程将在您的存储库中由事件(s)触发时运行,或者它们可以被手动触发或按定义的时间表触发。在先前的例子中,将触发工作流程的事件是向主分支推送和向 main 提交拉取请求。
我希望这一节很好地介绍了您对 GitHub Actions 的了解。当然,还有更多高级选项,如 matrix 和其他功能,但罗马不是一天建成的。您可以从基础知识开始,快速进步到专家,以便在您的发布管道中获得精细的控制。
摘要
CI 系统是当今软件工程流程中必不可少的部分,也是对 TDD 所做努力的延续。
本章介绍了 GitHub Actions 作为 CI 系统的一个好例子,并使用了 第十章 的代码作为现实世界的例子。通过完成本章,我相信您应该能够将 CI 配置添加到您的工具箱中。
在下一章中,我们将看到如何考虑将测试添加到现有项目中,因为我们并不总是有从头开始绿色项目的奢侈。
进一步阅读
要了解更多关于本章讨论的主题,您可以参考以下链接:
-
YAML 文件:
yaml.org/ -
GitHub Actions 工作流程:
docs.github.com/en/actions/using-workflows/about-workflows -
持续集成 by Martin Fowler:
martinfowler.com/articles/continuousIntegration.xhtml
第十二章:处理棕色地带项目
每当我听到棕色地带项目这个词时,我都会感到不舒服,可能你也是。设计决策已经做出,代码已经被前开发人员编写,代码质量在一类和另一类之间有所不同;棕色地带不是胆小鬼的领域。
由于“棕色地带”这个术语可能有多个定义,我想先在这里定义它,以便我们都在同一页上。从本书的角度来看,棕色地带项目是一个没有单元测试覆盖且可能已经编写了一段时间的项目。它可能被其他类型的测试而不是单元测试所覆盖,但我们将仍然称其为棕色地带。一些技术人员也将其称为遗留项目。
如您所已发现的,我们专门用了一整章来讨论棕色地带,因为将 TDD 或单元测试引入这样的项目存在挑战。我们将讨论这些挑战以及如何克服它们。
在本章中,我们将涵盖以下主题:
-
分析挑战
-
启用 TDD 的策略
-
为单元测试进行重构
到本章结束时,你将更好地了解在为你的项目启用单元测试时需要寻找什么。你还将对所需的代码更改有所了解。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/ch12
分析挑战
在前面的章节中,我们一直在谈论从单元测试端开始添加新功能(先测试后开发)。我们依赖于有一个新的功能或修改一个已经由现有单元测试覆盖的功能。对于棕色地带(brownfields)来说,情况并非如此,因为当你尝试应用 TDD 时,你将面临以下挑战:
-
依赖注入支持:一些遗留框架没有原生支持依赖注入(DI),这对于单元测试是必要的。
-
代码修改挑战:未覆盖的测试(任何类型的测试)的代码更改可能会引入新的错误。
-
时间和精力挑战:引入代码的单元测试能力需要时间和精力。
让我们详细地逐一分析每个挑战,以便你在需要时考虑它们。
依赖注入支持
在本书中,在学习单元测试或 TDD 之前,我们不得不介绍依赖注入(DI)。依赖注入(DI)是允许你将代码分割成单元/组件的工具;它是单元测试的自然要求。在启用依赖注入(DI)时有两个挑战——框架支持和重构工作。让我们深入了解。
依赖注入的框架支持
这是一本.NET 书籍,所以我们只对不支持原生依赖注入的遗留.NET 框架感兴趣。在 2000 年代初,当单元测试成为一种趋势时,微软更感兴趣的是将开发者从Visual Basic 6(VB6)和Active Server Pages(ASP)迁移过来,因此,在.NET 的早期阶段实现原生依赖注入并不是优先事项。
因此,Win Forms 和 ASP.NET Web Forms 在没有原生依赖注入支持的情况下诞生。当然,你可以通过修改框架来添加一些依赖注入的支持。然而,当你开始偏离框架的规范时,你会使其他在代码库上工作的开发者感到疏远,并给设计引入微妙的错误和复杂性。
更现代的框架,如 WPF 和来自经典.NET Framework 的 ASP.NET MVC,允许通过第三方依赖注入容器进行依赖注入。如今,随着 ASP.NET Core 的推出,依赖注入通过微软构建的依赖注入容器原生支持。
如果你有一个基于没有原生支持依赖注入的遗留框架(如 Win Forms 和 ASP.NET Web Forms)的项目,我会说,将这些框架弯曲以实现单元测试的努力需要权衡拥有单元测试的好处。也许你可以将这项努力投入到应用其他类型的测试到项目中。显然,将项目迁移到现代框架可以解决这个问题,但这也有其自身的挑战。
如果框架原生支持依赖注入或可以轻松支持依赖注入,那么你很幸运,但这就是全部吗?显然,现在你必须重构所有内容以启用依赖注入。
支持依赖注入的重构
我们在第二章理解依赖注入的示例中专门讨论了依赖注入,因此在这里我们不会深入探讨。当我们计划引入单元测试或 TDD 时,我们需要确保我们正在使用依赖注入来注入组件。
理想情况下,所有组件都需要通过构造函数注入进行注入,变量实例化不应在方法或属性代码中完成。话虽如此,考虑以下不受欢迎的代码:
MyComponent component = new MyComponent();
当你拥有未进行单元测试的代码时,你可能会发现所有组件都在代码中直接实例化,并且没有使用依赖注入容器。在这种情况下,你必须遍历直接实例化的案例,并将它们修改以支持依赖注入。我们将在本章末尾看到一个这样的例子。
并非所有直接实例化的案例都需要你为依赖注入进行重构。有些案例是标准库的一部分,你可以对其进行单元测试,但你不应该这样做。以下是一个例子:
var uriBuilder = new UriBuilder(url);
在这个例子中,我们没有打算注入UriBuilder类,所以你可能不需要更改代码,因为该类不依赖于外部依赖。因此,注入该类并不有益,反而增加了不必要的努力。
简而言之,为了使代码可进行单元测试,所有组件都需要准备好依赖注入。根据你的项目大小以及你想要实现的方式(例如迭代),这将需要时间和努力。
引入依赖注入不是唯一的挑战;修改代码也将带来新的挑战。
代码修改挑战
当你在项目中添加非单元测试时,你是在代码外部工作,以下是一些可能的活动:
-
使用 Selenium 或 Cypress 等自动化工具测试 UI。测试将像外部用户一样处理应用程序。
-
通过执行端到端调用进行集成测试,例如在 API 端点上。
-
通过创建应用程序的多个实例来对项目进行负载测试。
-
通过尝试从外部黑客攻击应用程序进行渗透测试。
所有这些活动都不需要更改代码,但单元测试要求生产代码处于某种形状。
当更改代码以启用单元测试时,我们冒着破坏它的风险。想象一下,一个错误找到了进入生产环境的方法,而业务方听到“它坏了,因为我们增加了测试”,这真是讽刺。
重要提示
如果我说你为了单元测试而必须更改代码,那我就撒谎了,因为你可以使用隔离测试框架,这会让你能够在不更改代码的情况下进行单元测试。然而,如果你真的想进行单元测试而无法更改代码,这将是一个最后的手段。我们将在本章后面进一步讨论这个问题。
有一些解决方案可以更改代码,从而降低出错的可能性,所以请继续阅读。
时间和努力挑战
启用依赖注入并将代码重构为作为单元操作的组件的过程在心理上具有挑战性且耗时。
考虑通过将过程迭代地划分为你的 sprint(或迭代,或你称之为什么)或通过阻塞一些迭代并实施你的更改来接近这个过程。
这里的挑战在于向业务方证明引入单元测试和启用 TDD 所花费的时间是合理的,因为从他们的角度来看,你们的产品还是一样的,同样的数量的问题,而且什么都没有修复,只是增加了测试。显然,我和你都知道单元测试可以保护代码免受未来错误的侵害并增加文档,但挑战在于将这一点传达给业务方。下一章将讨论在引入 TDD 和单元测试时如何处理业务问题,所以我在这里就先停下了。
所有这些挑战都有解决方案;毕竟,我们在软件行业工作!接下来的几节将使用不同的策略来处理这些问题。
启用 TDD 的策略
现在是讨论上一节中描述的挑战解决方案的时候了。由于“一张图片胜过千言万语”,我将展示一个工作流程图,以阐明如何将单元测试引入到一个棕色地带项目中:

图 12.1 – 在项目中启用 TDD 的工作流程
让我们来看看图表和我们的选项。
考虑重写
你可能会考虑重写,因为现有项目可能基于一个较旧的框架,开发者较少,支持也较少。然而,重写想法是有争议的。如果你告诉业务该项目需要重写,你将成为他们最不喜欢的个人。相信我;没有人想听到这个。然而,好的重写不需要是一次性的大爆炸;它可以分成更小的升级块,并可以附加到冲刺中。显然,选择一个原生支持 DI 或通过第三方支持的现代框架是不可能的。
重写软件有许多方法,这超出了本书的范围。但如果你正在重写,你可以从 TDD 开始新的部分,问题就会得到解决!
修改代码
在某些设置中,代码过于复杂,难以修改,或者有时业务出于任何原因不喜欢修改代码。如果你面临这些情况之一,那么请自问是否值得添加单元测试,或者是否应该将这项工作投入到其他类型的测试中。显然,其他类型的测试也会有益,尽管单元测试会更有益。
单元测试可以在没有 DI 的情况下进行;因此,你不需要更改代码。看这里,我已经泄露了这个秘密!但为了使其工作,你将不得不使用一个 测试隔离框架。测试隔离框架会对组件外部加载的方式做一些更改,而不触及代码。
例如,考虑这个类:
public class Warehouse
{
public Dictionary<string, int> Products { get; }
…
}
注意,该类没有实现接口,Products 属性也不是虚拟的。让我们看看 Telerik 的 JustMock 测试隔离框架如何对这个类相关的代码进行单元测试:
[Fact]
public void Complete_SampleInventory_IsCompleted()
{
// Arrange
var order = new Order("trouser", 1);
var warehouse = new Warehouse();
Mock.Arrange(() => warehouse.Products)
.Returns(new Dictionary<string, int>() {
{ "shirt", 12},
{ "trouser", 5}
});
// Act
order.Complete(warehouse);
// Assert
Assert.True(order.IsCompleted);
}
在这个代码块中,我们只关心两条被突出显示的行。Warehouse 类上的 Products 属性,尽管这个属性不是虚拟的,并且 warehouse 对象没有通过模拟库实例化。
JustMock 在这里施展了一些魔法,它使 Warehouse 类可模拟,尽管 Warehouse 没有接口,Products 也不是虚拟的。不需要 DI!
然而,隔离框架的魔法并不受大多数 TDD 实践者欢迎,因为它会导致不良的编程实践。此外,这些框架并非免费。当你想要避免代码更改时,它们确实解决了问题,但它们提出了一个问题:这是否值得付出努力和成本?
通过依赖非标准测试方式所带来的麻烦,将需要培训、维护和许可费用,这些成本应该与使用任何框架进行权衡。
原生支持 DI
.NET 框架中的一些框架没有 DI 的概念——Win Forms 和 Web Forms 是完美的例子。你可以强迫它们支持 DI,但这意味着扭曲框架并独自承担。有时,你可以尝试隔离 UI 层并对下面的内容进行单元测试。在这种情况下,这已经足够好了。
我想说的是,拥有一个不原生支持插入 DI 容器或内置了 DI 容器(如 ASP.NET Core)的框架将让你付出更多努力,并将你从规范中移开。
我会避免对这样的框架进行单元测试,并通过采用其他测试类别来提高质量。
单元测试之前的测试覆盖率
代码更改会导致错误,但仔细的代码更改呢?嗯,是的,它仍然会导致错误!无论你多么小心,更改代码时都会出现错误。那么,你的错误查找计划是什么?
如果你计划为了单元测试而更改代码,你的代码应该首先通过其他类型的测试达到高覆盖率,主要是自动化和集成测试。这些测试将帮助你指出在代码进入生产之前你破坏了哪些地方。
随着问题的出现,一个逻辑问题是,如果我已经有了其他类型测试的高覆盖率,为什么我还需要单元测试?以下是一些答案:
-
如果你的项目仍在开发中,那么你需要单元测试。此外,最好以 TDD 风格添加新功能。
-
当你的项目能够支持它们时,你可以将所有可用测试的平衡改变为单元测试,因为单元测试在我们这本书的第四章“使用测试替身进行真实单元测试”中讨论的具有其他测试的优势。
如果你的代码处于维护模式且覆盖率已经很高,那么我会说添加单元测试并不是非常有用。在这种情况下,TDD 不适用,因为 TDD 是新功能或功能变更的伴侣。
我的建议是,如果代码没有被测试覆盖,不要更改它,因为你在推进项目中的宝贵努力可能会被生产中的错误所抵消。也许应该将努力投入到其他测试或重写中。
每个项目都是不同的,我们在这里提到的策略只是需要考虑的点。当你计划将单元测试引入棕色地带时,你应该考虑将这些点加入你的思考过程中。
接下来,我们将看到将遗留代码更改为允许单元测试的示例。
为单元测试进行重构
当你在 TDD(测试驱动开发)中编写代码时,你的代码从第一刻起就是可单元测试的。这是因为你考虑了依赖注入(DI)的场景。棕色地带代码几乎从未考虑过 DI,它将不得不改变以适应它。
在本节中,我们将涵盖你必须更改的场景,然后在本节的末尾通过一个示例重构来展示。
代码中实例化的变量
当你在代码中看到用于实例化库或服务的 new 关键字时,那么很可能,这需要重构。以下是一个方法中的代码示例:
var obj = new Foo();
obj.DoBar();
上一行意味着我们无法注入 Foo 的测试替身,因此代码需要更改以注入它。
下一步是检查 Foo 是否实现了你从该类使用的方法的接口。让我在这里告诉你一个坏消息——保持你的期望低;除非你正在使用一个设计良好且复杂的框架,否则你很可能找不到该类实现了你使用的方法的接口。
在接下来的几节中,我们将通过使代码可测试的过程。
为你自己的类创建接口
如果你拥有 Foo 中的代码并且可以更改它,那太好了!你的代码可以从:
class Foo
{
public void DoBar();
}
为这个类和额外的接口 IFoo:
interface IFoo
{
void DoBar();
}
class Foo : IFoo
{
public void DoBar();
}
这很简单。但如果这个类的源代码不可用,或者你不允许更改源代码怎么办?
为第三方类创建接口
为你并不拥有的类添加接口是不可能的。你必须通过另一个模式,通常被称为包装类。你需要创建一个新的类和接口,如下所示:
interface IFooWrapper
{
void DoBar();
}
class FooWrapper : IFooWrapper
{
private Foo _foo = new();
public void DoBar() => _foo.DoBar();
}
你可以看到,我们已经用另一个类包装了 Foo 类,以拦截对 DoBar 方法的调用。这将允许我们以与我们添加到我们拥有的类中的接口相同的方式添加接口。
这里有一些额外的工作,但你会习惯的,经过几次类更改后,它将变得简单易懂。
现在我们为我们的类有了接口,我们可以进行第二步,即依赖注入。
注入你的组件
你如何进行依赖注入取决于你使用的库(ASP.NET Core、Win Forms 等)以及你如何配置你的 DI 容器。让我们以一个 ASP.NET Core WebAPI 项目为例。为了配置你新创建或更新的类,在 Program.cs 中编写类似于以下代码:
builder.Services.AddScoped<IFoo, Foo>();
或者以下代码:
builder.Services.AddScoped<IFooWrapper, FooWrapper>();
显然,生命周期范围(瞬态、作用域或单例)将根据 Foo 类而变化。
一旦完成修改,你可以重构你的控制器以注入 FooWrapper:
public class MyService
{
private readonly IFooWrapper _foo;
public MyService(IFooWrapper foo)
{
_foo = foo;
}
public void BarIt()
{
_foo.DoBar();
}
}
我们引入了一个 wrapper 类和一个接口,这样我们就可以遵循一个熟悉的依赖注入(DI)模式,因此之前的代码成为可能。
现在,你可以继续实施你想要的任何单元测试,因为你在测试时可以注入 FooWrapper 的测试替身。
实例化场景已经整理好了。让我们探索另一个重构模式。
替换静态成员
静态方法,包括扩展方法,简单、占用更少的代码行,并产生优美的代码。然而,当涉及到依赖注入时,它们是邪恶的;根据 第二章 的解释,通过示例理解依赖注入,静态方法不适合单元测试。
Date.Now看起来很无辜,Now是一个只读的静态属性。如果你想让你的单元测试冻结时间,例如,比如说你想测试 2 月 29 日(闰年)会发生什么,你做不到。这个问题的解决方案是一个包装器,如之前讨论的那样。这是你可以做的,将Now作为一个实例方法而不是静态方法:
public interface IDateTimeWrapper
{
DateTime Now { get; }
}
public class DateTimeWrapper : IDateTimeWrapper
{
public DateTime Now => DateTime.Now;
}
我们做了和之前一样的事情,当时我们没有控制这个类(几节之前)。我们通过向DateTime类引入包装模式来启用 DI 支持。现在,你可以在运行时注入DateTimeWrapper并使用测试替身进行单元测试。
如果你控制这个类,你可能想将静态成员更改为实例成员(非静态)或者引入一个额外的实例成员并保留静态成员:
Interface IFoo
{
string PropWrapper { get; }
}
class Foo : IFoo
{
public static string Prop => …
public string PropWrapper => Foo.Prop;
}
这是将你的静态属性公开为实例属性的一种方法。你还需要在其余代码中使用PropWrapper包装属性,而不是未包装的Prop。在之前的示例中,我们添加了一个额外的属性,但你也可以重构代码以替换静态属性,如果这样做有意义的话。
将消费者改为依赖于实例成员
消耗先前Foo类的代码可能看起来像这样:
public class Consumer
{
public void Bar()
{
…
var baz = Foo.Prop;
…
}
}
在根据上一节重构Foo之后,这里的实现可以改变为可测试的格式,如下所示:
public class Consumer
{
private readonly IFoo _foo;
public Consumer(IFoo foo)
{
_foo = foo;
}
public void Bar()
{
…
var baz = _foo.PropWrapper;
…
}
}
你可以看到,我们已经将IFoo注入到Consumer类中,并且我们使用了另一个属性,PropWrapper。
实例化的类和静态成员调用很容易被发现。然而,关于遗留代码最显著的一点是它没有结构,一个组件不能轻易被发现和测试。因此,为了这个,我们不得不做更多的改变。
改变代码结构
在棕色地带项目中,代码可能处于无法进行单元测试的格式。一个流行的结构是控制器动作方法,所有代码都写在其内部:
public void Post()
{
// plenty of code lines
}
在这里,我们需要将代码放入可测试的结构中。我会选择一个架构,如本书的第二部分中所述,在那里我们使用了服务和领域对象。
之前的示例代码运行良好,但它不可进行单元测试。你可以在本章的WeatherForecasterBefore目录下的WeatherForecastController.cs GitHub 文件中找到完整的列表:
public class WeatherForecastController : ControllerBase
{
public async Task<IEnumerable<WeatherForecast>>
GetReal([FromQuery]decimal lat, [FromQuery]decimal lon)
{
var res = (await OneCallAsync(lat, lon)).ToArray();
…
for (int i = 0; i < wfs.Length; i++)
{
…
wf.Summary = MapFeelToTemp(wf.TemperatureC);
}
return wfs;
}
private static async
Task<IEnumerable<(DateTime,decimal)>> OneCallAsync(
decimal latitude, decimal longitude)
{
var uriBuilder = new UriBuilder(
"https://api.openweathermap.org/data/2.5/onecall");
…
var httpClient = new HttpClient();
}
private static string MapFeelToTemp(int temperatureC)
{
…
}
}
显然,为了简洁起见,大部分代码被省略了。代码将调用一个名为 Open Weather 的第三方服务,并为某个地理坐标获取未来 5 天的天气预报。然后,它将分析温度并产生一个描述温度感觉的词,例如Freezing或Balmy。
之前的代码还实例化了一个HttpClient实例,这意味着在尝试对这段代码进行单元测试时,我们无法避免调用第三方服务。
接下来,我们将思考如何将此代码改为可测试组件。
分析代码更改以实现可测试格式
我们刚才看到的代码可以通过几种方式制作成组件,并且没有一种方法可以做到。这段代码做了两件事,因此我们可以考虑两个将包含所有代码功能的组件:
-
调用 Open Weather 并获取预报
-
获取预报并分析
这里的想法是拥有没有业务逻辑的控制器,如果没有业务逻辑,那么我们就不需要单元测试控制器。一般来说,控制器应该没有业务逻辑,它应该做单一的工作——将 数据传输对象 (DTOs) 传递给视图(如模型-视图-控制器视图)。
我们将给我们的组件以下命名:
-
OpenWeatherService -
WeatherAnalysisService
获取预报和温度感觉分析的整体调用将看起来像这样:

图 12.2 – 组件工作流程
客户端将调用 API 以感觉获取预报。天气预报控制器将接收调用并将其传递给天气分析服务,该服务加载 Open Weather 服务并调用外部依赖以获取天气。
接下来,我们将看到重构后的代码看起来是什么样子。
最终可测试的代码
当您想启用单元测试时,存在重构侵入性的级别。我选择了积极级别,但您可能选择重构更少的代码。
您可以在 WeatherForecasterAfter 目录中看到整个重构后的代码。
现在控制器看起来是这样的:
public class WeatherForecastController : ControllerBase
{
private readonly IWeatherAnalysisService
_weatherAnalysisService;
public WeatherForecastController(
IWeatherAnalysisService weatherAnalysisService)
{
_weatherAnalysisService = weatherAnalysisService;
}
[HttpGet]
public async Task<IEnumerable<WeatherForecast>>
GetReal(
[FromQuery]decimal? lat, [FromQuery]decimal? lon)
{
if (lat is null || lon is null)
{
return await _weatherAnalysisService
.GetForecastWeatherAnalysis();
}
return await _weatherAnalysisService
.GetForecastWeatherAnalysis(lat.Value, lon.Value);
}
}
与之前相比,控制器几乎是空的。控制器中的操作方法将 API 调用映射到正确的服务。
这是 OpenWeatherService 类:
public class WeatherAnalysisService :
IWeatherAnalysisService
{
…
private readonly IopenWeatherService
_openWeatherService;
public WeatherAnalysisService(
IOpenWeatherService openWeatherService)
{
_openWeatherService = openWeatherService;
}
public async Task<IEnumerable<WeatherForecast>>
GetForecastWeatherAnalysis(decimal lat, decimal lon)
{
OneCallResponse res = await
_openWeatherService.OneCallAsync(…)
…
}
private static string MapFeelToTemp(int temperatureC)
{
…
}
}
该类包含将感觉映射到温度并调用 OpenWeatherService 的逻辑。服务不知道如何调用 Open Weather API。
最后,让我们看看 OpenWeatherService:
public class OpenWeatherService : IOpenWeatherService
{
…
public OpenWeatherService(string apiKey,
HttpClient httpClient)
{
_apiKey = apiKey;
_httpClient = httpClient;
}
public async Task<OneCallResponse> OneCallAsync(
decimal latitude, decimal longitude,
IEnumerable<Excludes> excludes, Units unit)
{
…
}
}
这个服务的任务是使用代码封装对互联网 Open Weather API 的 HTTP 调用。
新增服务完全单元测试可以在与其余服务相同的源代码目录中找到。
记住,我们在假设代码已经经历过其他类型的测试的情况下进行重构。积极重构代码是耗时的,尤其是对于第一组重构,但请记住,重构也是偿还项目部分技术债务。现在代码由单元测试进行文档化,所以这是一个进步。
摘要
在本章中,我们讨论了为现有项目启用单元测试的后果。我们已经讨论了考虑因素,以便您决定这是否值得,以及在推进过程中需要注意的所有事情。
作为一名开发者,您将遇到价值上升的棕色地带项目,这些项目将从单元测试和 TDD 中受益。希望这一章能为您提供应对这些项目的所需知识。
决定将 TDD 引入您的组织并非一个简单的过程。下一章将详细介绍这一过程,并为您准备您可能会遇到的一些场景。
进一步阅读
要了解更多关于本章讨论的主题,您可以参考以下链接:
-
JustMock(来自 Telerik 的隔离框架):
docs.telerik.com/devtools/justmock -
Microsoft Fakes(随 VS Enterprise 提供的隔离框架):
docs.microsoft.com/en-us/visualstudio/test/isolating-code-under-test-with-microsoft-fakes -
TypeMock(适用于经典.NET Framework 的隔离框架):
www.typemock.com
第十三章:推广 TDD 的复杂性
我经常看到开发者投入精力试图说服业务遵循 TDD 或采用单元测试。实际上,这是我经常遇到的情况,因此我想在本章中与你分享我的经验。
在阅读这本书之后,你可能会强烈地想要在你的直接团队或大型组织中实施 TDD 以获得质量效益。到目前为止,一切顺利。第二阶段是以一种结构化的方式进行,并准备好应对业务的反对和拒绝。
我们将突出挑战,并指导你通过说服你的业务和团队采用 TDD 方法的过程。在本章中,我们将讨论以下主题:
-
技术挑战
-
团队挑战
-
业务挑战
-
TDD 论点和误解
在阅读本章之后,你将准备好向你的团队和/或业务展示一个计划,以继续实施 TDD。
技术挑战
在采用 TDD 之前,组织必须克服一系列技术和业务挑战。在这里,我们将涵盖技术挑战,在下一节中,我们将考虑团队挑战,然后是更广泛的组织挑战(业务挑战)。我们将从一个图表开始,以解释在你的组织中推广 TDD 的工作流程:

图 13.1 – 计划迁移到 TDD 时的技术挑战
我们将在下一小节中逐步分析这个图表,所以让我们开始吧。
绿色地带或棕色地带?
如果你正在从事一个棕色地带项目,前一章已经很好地介绍了技术挑战,所以我就不再深入这些挑战了。为了引入 TDD,你需要考虑努力程度、适用性和替代方案。
如果你正在启动一个新项目(一个绿色地带项目),那么你很幸运。你可以继续你的计划。
工具和基础设施
现在,随着云服务的可用性,拥有运行你的持续集成(CI)管道的基础设施既容易又便宜。然而,一些组织对使用云有限制,你可能会发现很难获得 CI 服务器。
如果你还没有设置 CI 服务器,那么听起来可能有些悲观,但进行 TDD 注定会失败。这是因为开发者可能会破坏单元测试,导致测试被禁用或失败。
一些开发者也喜欢投资于像JetBrains ReSharper这样的工具,因为它有一个高质量的测试运行器和重构功能,但这不是必需的。此外,你可能还想考虑 JetBrains Rider,因为它具有 ReSharper 的所有功能,正如本书第一章中讨论的那样。
然而,如果你正在使用 MS Visual Studio Professional 2022 或更高版本,你已经有了一个适合 TDD 过程的良好工具。
技术挑战并不是你需要考虑的全部。还要考虑你的团队是否准备好接受 TDD,以及你的业务挑战。让我们继续讨论团队挑战。
团队挑战
如果你是一个独立开发者,正在处理一个项目,那么不用担心,你可以做任何你想做的事情。然而,大多数商业项目都是由团队实施的,因此努力使用 TDD 是一个团队的决定。再次强调,让我们从一个工作流程图开始:

图 13.2 – 计划迁移到 TDD 时的团队挑战
我们将在下一节中详细讨论这个图表。让我们回顾一下在计划让你的团队迁移时需要注意的要点——无论你是希望影响团队的开发者,还是处于可以强制执行技术标准的职位。
团队经验
单元测试需要依赖 DI(依赖注入),而这又反过来需要 OOP(面向对象编程)的经验。你的团队成员可能对单元测试不熟悉,或者可能会将单元测试与集成测试混淆。
重要注意事项
xUnit 和 NUnit 库被广泛用于实现集成测试。因为它们有后缀Unit,开发者有时会错误地假设编写的测试是单元测试。我见过一些团队声称他们有单元测试,但当我检查代码时,我发现并非如此。
如果你的团队需要 TDD(测试驱动开发)的培训,那么他们需要了解 TDD 是什么,如何进行,以及 TDD 的价值。我心中有一本推荐的书,但我会让你来猜测是哪一本。
重要注意事项
我通常会在团队进行关于某个主题的会议或几场会议之前,要求他们自己阅读某些材料。培训一个团队可以通过许多方式完成,这更多与你的公司和团队文化相关。我还会在 Confluence 或其他组织用于文档的工具上记录约定和协议。
在团队中有一个理解单元测试、善于解释并能抽出时间(这可能就是你)的合格开发者是很重要的。这将是有帮助的,因为当你的团队开始进行 TDD 时,他们会有问题。
但由于许多原因,培训团队可能不是一个选择。让团队中的一些成员进行单元测试,而其他人不,将不会产生效益,因为每个人都将操作相同的代码库,所以让每个人都接受培训并准备好进行 TDD 是一个先决条件。
愿意
一些团队不愿意进行单元测试,无论他们觉得这很困难,还是认为这会增加开发时间,或者他们没有意识到其价值。
重要注意事项
我看到过组织强制执行单元测试,但团队不愿意编写测试,他们只是创建了一个包含无意义测试的单元测试项目,只是为了勾选“你是否实现了单元测试?”这个问题。
让团队与目标保持一致,并为了一个共同的目标进行协作,这对提高产品质量是有价值的。
如果你的团队因为任何原因不愿意采用 TDD,但单元测试是可以接受的,那么就采用它!你可以在不久之后温和地引入 TDD。它不需要全有或全无。也值得注意,一些成员可以进行 TDD,而其他人可以进行单元测试。
单元测试无用
我已经从许多开发者那里听到过这个论点。他们可能对单元测试的糟糕实现形成了自己的看法,或者有其他原因。当然,单元测试有一些缺点,但大多数技术也是如此。
你最好的办法是了解这种误解背后的原因,看看你是否可以解决它们。
TDD 无用,我会做单元测试
TDD 是有争议的,有时开发者有自己的经验告诉他们 TDD 是不可用的。这是可以接受的,只要他们对单元测试感到满意,因为并非所有团队成员都必须进行 TDD。
如果不相信单元测试的开发者是在看到不良实践时构建他们的论点,那么你的工作可能就是推广良好的实践。
团队愿意遵循 TDD 对项目的成功有重要影响,因此确保每个人都站在同一条线上是很重要的。
时间
TDD 需要一些准备和额外的努力来获得基本的质量,遵循“没有痛苦,就没有收获”的格言。正确的时间很重要,它绝对不应该在发布时间或团队压力大的时候。
最好的时机是在项目开始时引入,但稍后引入也无妨。
一旦你通过了第一和第二次挑战,你将面临商业挑战,这可能是最困难的。
商业挑战
这里的商业意味着团队外的更高技术权威,他们可以执行规则。它也可以是项目经理或产品负责人。
我相信,TDD 或单元测试的成功推广是从上到下的,从管理层来说。执行可以来自:
-
开发部门负责人
-
开发经理
-
团队领导
-
技术负责人
-
IT 审计
如果这是一个个人倡议或团队倡议,在交付压力下,团队可能会考虑放弃它。然而,如果他们负责提供作为交付一部分的单元测试,包括覆盖率水平,那么它不能被忽视。
让我们从商业的角度来思考 TDD,这样我们就能更好地准备和表达我们的观点。
TDD 的商业效益
我们很清楚从技术角度来看 TDD 的好处。但企业会更愿意从商业角度看待这些好处,所以让我们深入探讨。
错误更少
这显然是最大的卖点,因为没有人喜欢错误。一些企业在他们的产品中遭受了大量的缺陷,而拥有更少的缺陷无疑是受欢迎的承诺。
唯一的问题是,很难通过统计数据证明更少的错误数量——项目将从第一天开始就有单元测试,所以我们无法比较之前和之后。
项目的实时文档
商业界担忧的一件事是文档,它与开发人员的流动率紧密相关。风险是如果开发人员离职,一些商业知识就会丢失。为了防止这种情况,重要的是要稳健地记录商业规则,而且,坦白说,我想不出有任何工具比单元测试更适合这项工作。
项目文档包含无法由单元测试覆盖的文档,例如项目架构。然而,几个月后没有人会记住的详细业务规则将由单元测试覆盖,并且随着每个开发人员的源代码提交进行监控。
将单元测试作为文档工具来推广是非常强大的,并且会让商业界倾听你的意见。
测试资源较少
在过去,手动测试占据了软件开发生命周期(SDLC)的大部分。今天,随着单元测试和其他自动化测试的出现,手动测试的规模和所需手动测试人员数量都减少了。一些组织甚至完全取消了手动测试,转而采用自动化测试(包括单元测试)。
因此,单元测试的承诺是使用较少的测试人员几乎不需要回归测试时间来覆盖大量的边缘情况和业务规则。
重要提示
回归测试是通过现有功能来确保其仍然正常工作。这通常发生在新版本发布之前。
显然,测试资源较少意味着成本较低,而时间较少意味着更快地发布功能,这自然引出了下一个话题。
短周期发布的能力
今天,在更敏捷的组织中,开发模型已经转变为时不时地发布一些功能。
每次更改时都有单元测试回退代码,并且有一个 CI/CD 系统,这意味着你的软件随时可以发布。
没有哪个聪明的商人会相信上述所有好处都是免费的,所以接下来,我们将讨论单元测试的缺点。
从商业角度的缺点
通常,额外的质量需要更多的努力,TDD 也不例外,但幸运的是,缺点很小。
首次发布的轻微延迟
我们之前讨论过,不使用 TDD 的团队在初期往往交付得更快;我们曾在第五章,测试驱动开发解释中提到过这一点。这里有一个快速提醒:

图 13.3 – TDD 与无单元测试的比较
这里的想法是,编写单元测试的努力在短期内会增加开发时间,但在中期和长期内速度会更快。
这是为了质量而付出的微小代价,但请记住这一点。
首次发布的延迟是不可接受的
在某些情况下,企业希望尽快得到第一个版本,并且他们对此以外的内容不感兴趣。以下是一些场景:
-
产品经理希望尽快推出第一个版本,因为这可以让他们获得更高的奖金或晋升。
-
尽快发布并且不担心未来的竞争优势。这是初创公司试图生存的心态。
-
这个项目是为第三方完成的,商业方面不会因为确保额外的质量而获得额外报酬。但尽可能快地完成这是意图。
如果商业人士不感兴趣,这将很清楚,如果你了解公司使用的商业模式,你可以在事先感觉到这一点。这并不是对 TDD 的批评,但如果与这种场景混合,它将变成一个劣势。
现在我们已经讨论了 TDD 的所有挑战和优点,让我们制定一个引入 TDD 的计划。
TDD 的论点和误解
这里有一些提示和技巧——来自我自己的经验——这些在商业人士或你的同事的对话中会反复出现。
单元测试,而非 TDD
在与商业人士讨论时,为了减少对话的复杂性,特别是如果商业人士不太懂技术,请使用单元测试而不是 TDD。TDD 是一个个人会自己执行的技术流程,它与商业没有直接关系,所以为什么要在讨论中增加复杂性呢?有时商业人士已经听说过 TDD 这个术语,并且对此很兴奋,所以在这种情况下使用 TDD 是正确的术语!
我的建议是,除非企业对 TDD 这个术语有偏好,否则在对话中使用单元测试。
单元测试不是由测试员实现的
单元测试中的“测试”一词对非技术人员来说具有误导性,因为它暗示了一个进行手动测试的测试员。我与许多商业人士都有过这样的对话。
需要明确的是,单元测试除了测试之外还有更多功能,如下所示:
-
构建项目的代码设计架构
-
在开发过程中实时记录代码
-
在违反业务规则时开发过程中的即时反馈
此外,单元测试是用 C#(或你正在使用的任何其他语言)编写的,并且是由编写代码的同一开发者实现的。一个手动测试员,很可能不会有编写这些测试的意愿或专业知识。
当企业想知道为什么你想让工程师花费宝贵的时间进行单元测试,而此时有测试员可以(正如他们最初所想)进行单元测试时,可能会引发这种论点。
编写和维护文档的方式
我相信有经验的商业人士会理解缺乏文档或文档过时的情况。
正如您所知,使用单元测试来记录代码提供了比基于文本的陈旧文档更新的文档,后者是编写后就被遗忘或只覆盖系统的一部分(有中也有不中)。这里的重点是最新、新鲜或实时。显然,我们是在谈论文档的一部分,您可能需要明确这一点。这是详细业务规则的部分。
我们有不胜任的开发者
企业有时认为他们有不胜任的开发者,这就是为什么他们会产生大量错误。我多次听到业务在谈论他们的团队时低声说出这个论点。
当我听到这个论点时,我会迅速深入挖掘,发现业务没有建立敏捷流程的结构,并且开发者因快速完成开发功能而受到奖励。我们都知道那个人,他们总是选择最短的路来完成他们的功能并向业务炫耀!
开发者是非常逻辑性的人,他们喜欢结构化和秩序。拥有 TDD 的开发流程肯定会减少错误并将事情引上正轨。
您的挑战是展示 TDD 过程和测试将如何对问题产生积极影响。
摘要
本章利用了本书中提供的所有知识,展示了将 TDD(测试驱动开发)推广到组织中的挑战。我希望我已经提供了足够的论据来说服团队和业务接受 TDD 的观点。
除了本章内容外,在计划推广 TDD 时,您的演讲技巧和对主题的熟悉程度将非常有用。
在这本书中,我努力提供了我实际使用过的真实框架和工具的实用示例,而不是使用抽象和过于简化的示例。我出于对这一主题的热爱和激情而写这本书,并试图保持务实,我希望我已经实现了我的目标。
尽管这本书的标题提到了 TDD,但本书包含了面向对象和良好编程实践的实用示例,我相信您在完成这本书后已经进入了高级软件工程的领域。
祝你好运,并且我很乐意了解这本书是如何帮助您或您的团队采用 TDD 的。
附录 1:带有单元测试的常用库
我们在整本书中使用了两个主要的单元测试库:
-
xUnit
-
NSubstitute
您的团队可能已经在使用这些库了。或者您可能已经对单元测试进行了一些实验,并希望扩大视野到更多的库。虽然这些库很受欢迎,但其他库可以替代它们或与它们并行工作。本附录将简要介绍以下库:
-
MSTest
-
NUnit
-
Moq
-
流畅断言
-
AutoFixture
所有这些库都使用 MIT 许可证,这是最宽松的许可证,您可以通过 NuGet 安装它们中的任何一个。
到本附录结束时,您将熟悉构成.NET 单元测试生态系统的库。
技术要求
本章的代码可以在以下 GitHub 仓库中找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/appendix1
单元测试框架
我们已经看到了 xUnit,我们也简要地讨论了 MSTest 和 NUnit。本节将让您了解这些其他框架的内容。
MSTest
MSTest 以前很受欢迎,因为它在 VS 的旧版本中作为 Visual Studio (VS) 的一部分安装。在 NuGet 存在之前,使用一个 内置 库可以减少配置和部署时间,与添加和使用另一个框架(如 NUnit)相比。
在 NuGet 之前,安装一个新的库涉及到手动复制 DLL 文件,将它们放在正确的位置,更改一些配置,并将它们推送到源代码控制,以便团队共享相同的文件。因此,拥有预先安装的库且不需要配置的库,如 MSTest,是一种祝福。从那时起,我们已经走了很长的路。
要将 MSTest 项目添加到您的解决方案中,您也可以通过 UI 来完成:

图 A1.1 – 通过 UI 添加 MSTest
注意,有两个 C# 版本的副本。下面一个是用于经典 .NET Framework 的,上面一个是我们在 .NET Core 中使用的。
您可以通过 dotnet CLI 添加 MSTest:
dotnet new mstest
MSTest 和 xUnit 有类似的语法,所以让我向您展示 xUnit 的代码及其在 MSTest 中的等效代码。我将从 xUnit 开始:
public class WeatherAnalysisServiceTests
{
…
public WeatherAnalysisServiceTests()
{
_sut = new (_openWeatherServiceMock);
}
[Fact]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
…
// Assert
Assert.Equal(LAT, actualLat);
Assert.Equal(LON, actualLon);
}
…
MSTest 中的对应代码如下:
[TestClass]
public class WeatherAnalysisServiceTests
{
…
[TestInitialize]
public void TestInitialize()
{
_sut = new(_openWeatherServiceMock);
}
[TestMethod]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
{
…
// Assert
Assert.AreEqual(LAT, actualLat);
Assert.AreEqual(LON, actualLon);
}
…
您可以直接看到这两个代码片段之间的一些差异:
-
MSTest 中的单元测试类必须用
TestClass装饰。 -
MSTest 中的构造函数可以工作,但标准的初始化方式是用
TestInitialize装饰一个方法。 -
两个库都使用
Assert类名,但类中的方法名不同;例如,xUnit 使用Equal和True,而 MSTest 使用AreEqual和IsTrue。
在进行多个测试时,xUnit 和 MSTest 使用不同的属性。以下是在 xUnit 中的代码:
[Theory]
[InlineData("Freezing", -1)]
[InlineData("Scorching", 46)]
public async Task GetForecastWeatherAnalysis_
Summary_MatchesTemp(string summary, double temp)
{
…
在 MSTest 中,相应的代码将看起来像这样:
[DataTestMethod]
[DataRow("Freezing", -1)]
[DataRow("Scorching", 46)]
public async Task GetForecastWeatherAnalysis_
Summary_MatchesTemp(string summary, double temp)
{
…
在这里,您可以注意到两个差异:
-
Theory变成了DataTestMethod。 -
InlineData变成了DataRow。
如您所见,这两个库之间没有太大的区别。此外,执行测试运行、运行测试资源管理器和其他测试活动(除了代码之外)都保持不变。
NUnit
NUnit 在两千年代的前十年中曾经是主导库;它至今仍在使用,而 xUnit 正变得越来越普遍。
要将 NUnit 项目添加到您的解决方案中,您可以通过 UI 来完成:

图 A1.2 – 通过 UI 添加 NUnit
就像 MSTest 一样,NUnit 有两个.NET 版本。下面一个是用于经典.NET Framework 的,上面一个是我们在.NET Core 中使用的。
你可以通过dotnet CLI 添加 NUnit:
dotnet new nunit
NUnit 和 xUnit 有相似的语法,所以让我给你展示 NUnit 的代码及其在 MSTest 中的等效代码。我将从 xUnit 开始:
public class WeatherAnalysisServiceTests
{
…
public WeatherAnalysisServiceTests()
{
_sut = new (_openWeatherServiceMock);
}
[Fact]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
…
// Assert
Assert.Equal(LAT, actualLat);
Assert.Equal(LON, actualLon);
}
…
在 MSTest 中的等效代码如下:
public class WeatherAnalysisServiceTests
{
…
[Setup]
public void Setup()
{
_sut = new(_openWeatherServiceMock);
}
[Test]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
{
…
// Assert
Assert.That(actualLat, Is.EqualTo(LAT));
Assert.That(actualLon, Is.EqualTo(LON));
}
…
你可以直接在这两个代码片段中找出一些不同之处:
-
在 NUnit 中,构造函数可以工作,但标准的初始化方式是用
Setup装饰一个方法。 -
两个库都使用
Assert类名,但类中的方法名不同;例如,xUnit 使用Equal,而 NUnit 使用AreEqual。 -
NUnit 的风格使用流畅的接口设计,推荐测试相等的方法是使用
That和Is.EqualTo。
在进行多个测试时,xUnit 和 NUnit 使用不同的类名。这段代码在 xUnit 中:
[Theory]
[InlineData("Freezing", -1)]
[InlineData("Scorching", 46)]
public async Task GetForecastWeatherAnalysis_
Summary_MatchesTemp(string summary, double temp)
{
…
在 NUnit 中,等效的代码将如下所示:
[Theory]
[TestCase("Freezing", -1)]
[TestCase("Scorching", 46)]
public async Task GetForecastWeatherAnalysis_
Summary_MatchesTemp(string summary, double temp)
{
…
在这里,你可以注意到InlineData变成了TestCase。除此之外,这两个库之间没有太大的区别,它们的模板包含在 VS 2022 的默认安装中。
这三个库可以互换使用,并且语法变化很小。一旦你习惯了其中一个,切换到另一个将花费极短的时间。
模拟库
.NET 中不缺少模拟库;然而,使用最多的两个库是 NSubstitute 和 Moq。在这本书中我们已经涵盖了大量的 NSubstitute 示例,所以让我们看看 Moq 是如何工作的。
Moq
Moq 与 NSubstitute 在角色和功能上大致相同。鉴于这本书使用的是 NSubstitute,最快的方式是通过比较这两个库来引入 Moq。让我们从一个 NSubstitute 的片段开始:
private IOpenWeatherService _openWeatherServiceMock =
Substitute.For<IOpenWeatherService>();
private WeatherAnalysisService _sut;
private const decimal LAT = 2.2m;
private const decimal LON = 1.1m;
public WeatherAnalysisServiceTests()
{
_sut = new (_openWeatherServiceMock);
}
[Fact]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
{
// Arrange
decimal actualLat = 0;
decimal actualLon = 0;
_openWeatherServiceMock.OneCallAsync(
Arg.Do<decimal>(x => actualLat = x),
Arg.Do<decimal>(x => actualLon = x),
Arg.Any<IEnumerable<Excludes>>(),
Arg.Any<Units>())
.Returns(Task.FromResult(GetSample(_defaultTemps)));
// Act
await _sut.GetForecastWeatherAnalysis(LAT, LON);
// Assert
Assert.Equal(LAT, actualLat);
Assert.Equal(LON, actualLon);
}
这个片段实例化并创建了一个从IOpenWeatherService生成的模拟对象,并监视了OneCallAsync的lat和lon输入参数。目的是确保传递给GetForecastWeatherAnalysis的两个参数未经修改地传递给OneCallAsync方法。
让我们看看使用 Moq 的相同代码:
private IOpenWeatherService _openWeatherServiceMock =
Mock.Of<IOpenWeatherService>();
private WeatherAnalysisService _sut;
private const decimal LAT = 2.2m;
private const decimal LON = 1.1m;
public WeatherAnalysisServiceTests()
{
_sut = new (_openWeatherServiceMock);
}
[Fact]
public async Task GetForecastWeatherAnalysis_
LatAndLonPassed_ReceivedByOpenWeatherAccurately()
{
// Arrange
decimal actualLat = 0;
decimal actualLon = 0;
Mock.Get(_openWeatherServiceMock)
.Setup(x => x.OneCallAsync(It.IsAny<decimal>(),
It.IsAny<decimal>(),
It.IsAny<IEnumerable<Excludes>>(),
It.IsAny<Units>()))
.Callback<decimal, decimal,
IEnumerable<Excludes>, Units>((lat, lon, _, _) => {
actualLat = lat; actualLon = lon; })
.Returns(Task.FromResult(GetSample(_defaultTemps)));
// Act
await _sut.GetForecastWeatherAnalysis(LAT, LON);
// Assert
Assert.Equal(LAT, actualLat);
Assert.Equal(LON, actualLon);
}
这个 Moq 代码与它的 NSubstitute 竞争对手看起来并没有太大的区别。让我们分析一下它们之间的差异:
-
NSubstitute 使用
Substitute.For方法实例化模拟对象,而 Moq 则使用Mock.Of。 -
NSubstitute 使用扩展方法,如
Returns来配置模拟对象,而 Moq 则不使用扩展。 -
NSubstitute 使用
Args.Any来传递参数,而 Moq 使用It.IsAny。
通常,虽然 Moq 倾向于使用 lambda 表达式语法,但 NSubstitute 则走另一条路,使用扩展方法。NSubstitute 试图让代码看起来尽可能自然,并通过减少语法来让代码更简洁,而 Moq 则依赖于 lambda 表达式的力量。
重要提示
Moq 有另一种创建模拟的方法。我选择展示现代版本。
在我看来,使用一个库还是另一个库是一个关于风格和语法偏好的问题。
单元测试辅助库
我看到一些开发者将这两个库添加到他们的单元测试中,以增强语法和可读性:Fluent Assertions和AutoFixture。
Fluent Assertions
流畅实现,也称为流畅接口,试图使代码读起来像英语句子。以这个例子为例:
Is.Equal.To(…);
一些开发者喜欢以这种方式编写测试,因为它支持更自然的测试阅读方式。有些人有自己的原因喜欢它。
FluentAssertions是一个流行的库,它集成了 MSTest、Nunit 和 xUnit 等所有流行的测试框架,以启用流畅接口。您可以通过 NuGet 在单元测试项目中添加它,名称为FluentAssertions。
让我们看看没有和有这个库时我们的代码会怎样:
// Without
Assert.Equal(LAT, actualLat);
// With
actualLat.Should().Be(LAT);
但之前的代码片段并没有展示库的真实力量,所以让我们看看其他一些例子:
// Arrange
string actual = "Hi Madam, I am Adam";
// Assert actual.Should().StartWith("Hi")
.And.EndWith("Adam")
.And.Contain("Madam")
.And.HaveLength(19);
之前的代码片段是一个流畅语法的例子,代码不言自明。要测试此代码,你需要几行标准的Assert语法。
这里还有一个例子:
// Arrange
var integers = new int[] { 1, 2, 3 };
// Assert
integers.Should().OnlyContain(x => x >= 0);
integers.Should().HaveCount(10,
"The set does not contain the right number of elements");
之前的代码也是不言自明的。
重要提示
虽然这些代码片段展示了FluentAssertions的强大功能,但在单元测试中断言过多不相关的元素并不推荐。这些示例仅用于说明,并不专注于最佳单元测试实践。
这两个代码片段足以说明为什么一些开发者喜欢这种语法风格。现在你了解了它,使用这种语法的选择权在你。
AutoFixture
有时候,你必须生成数据来填充一个对象。这个对象可能与你的单元测试直接相关。或者你可能只是想填充它以便单元测试执行,但它不是测试的主题。这就是AutoFixture发挥作用的时候。
你可以编写生成对象的繁琐代码,或者你可以使用AutoFixture。让我们用一个例子来说明这一点。考虑以下record类:
public record OneCallResponse
{
public double Lat { get; set; }
public double Lon { get; set; }
…
public Daily[] Daily { get; set; }
}
public record Daily
{
public DateTime Dt { get; set; }
public Temp Temp { get; set; }
…
}
// More classes
在单元测试的Arrange部分填充这些内容将会增加单元测试的大小,并使测试偏离其真正意图。
AutoFixture 可以使用最少的代码创建此类的一个实例:
var oneCallResponse = _fixture.Create<OneCallResponse>();
这将创建一个此类对象,并用随机值填充它。以下是一些值:
{OneCallResponse { Lat = 186, Lon = 231, Timezone = Timezone9d27503a-a90d-40a6-a9ac-99873284edef, TimezoneOffset = 177, Daily = Uqs.WeatherForecaster.Daily[] }}
Daily: {Uqs.WeatherForecaster.Daily[3]}
EqualityContract: {Name = "OneCallResponse" FullName =
"Uqs.WeatherForecaster.OneCallResponse"}
Lat: 186
Lon: 231
Timezone: "Timezone9d27503a-a90d-40a6-a9ac-99873284edef"
TimezoneOffset: 177
之前的输出是OneCallResponse类的第一级,但所有后续级别也都已填充。
但如果你想要对生成数据进行精细控制呢?比如说,我们想要生成一个类,但Daily属性具有8个数组元素而不是随机大小:
var oneCallResponse = _fixture.Build<OneCallResponse>()
.With(x => x.Daily,_fixture.CreateMany<Daily>(8).ToArray())
.Create();
这将随机生成一切,但Daily属性将包含八个具有随机值的数组元素。
这个库有大量的方法和自定义选项;本节仅触及表面。
本附录简要介绍了用于或与单元测试结合使用的几个库。这里的目的是告诉你这些库的存在,并在需要时激发你进一步探索。
进一步阅读
要了解更多关于本章讨论的主题,你可以参考以下链接:
-
xUnit:
xunit.net -
MSTest:
docs.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest -
NUnit:
nunit.org -
Moq:
github.com/moq/moq4 -
Fluent Assertions:
fluentassertions.com -
AutoFixture:
github.com/AutoFixture
附录 2:高级模拟场景
本书有大量直接的模拟场景示例。好消息是,在一个干净的代码环境中,实现大多数模拟需求将会很容易。
然而,有时你必须创新一点,才能模拟你想要的类。我不想在没有给你一个场景的情况下结束这本书,所以,这里就是。
在这个附录中,我们将体验如何将一个模拟与一个伪造结合使用来处理一个名为HttpMessageHandler的.NET 类。在本附录结束时,你将熟悉更多 NSubstitute 功能,并准备好处理更高级的模拟案例。
技术要求
本章的代码可以在以下 GitHub 仓库找到:
github.com/PacktPublishing/Pragmatic-Test-Driven-Development-in-C-Sharp-and-.NET/tree/main/appendix2
编写 OpenWeather 客户端库
本书频繁使用了 OpenWeather 服务,所以我会给你一个快速提醒——OpenWeather 提供了一套 RESTful API,可以为你提供天气和预报。
要从 C#应用程序中消费服务,有一个将 RESTful API 调用转换为 C#并保护开发者免受 HTTP 细节困扰的库会很好。我们称这种类型的库为RESTful 客户端库,有时也称为软件开发工具包(SDK)。
我们将使用 TDD(当然!)来构建这个服务的 SDK,在这个过程中,我们将遇到更多高级的模拟需求。
单一调用 API
OpenWeather 有一个名为One Call的 API,可以获取今天的天气信息和未来几天的预报。最好的说明方法是通过一个示例来获取伦敦格林威治的天气和预报。
首先,发起一个 RESTful GET请求。你可以使用浏览器来做这件事:
https://api.openweathermap.org/data/2.5/onecall?
lat=51.4810&lon=0.0052&appid=[yourapikey]
注意,前两个查询字符串参数是格林威治的纬度和经度,最后一个是你的 API 密钥(此处省略)。您将获得类似以下响应:
{
"lat":51.481,
"lon":0.0052,
"timezone":"Europe/London",
"timezone_offset":3600,
"current":{
"dt":1660732533,
"sunrise":1660711716,
"sunset":1660763992,
"temp":295.63,
"feels_like":295.76,
"pressure":1011,
"humidity":70,
…
这是一个非常长的 JSON 输出;大约有 21,129 个字符。
创建解决方案骨架
我们已经创建了一个库并频繁测试它,所以在这里我们也将这样做:
-
创建一个库项目,命名为
Uqs.OpenWeather,并删除示例类。 -
创建一个 xUnit 项目,并将其命名为
Uqs.OpenWeather.Test.Unit。 -
从测试项目添加对库的引用。
-
将 NSubstitute 从 NuGet 添加到测试项目中。
-
将单元测试中的类和文件名重命名为
ClientTests.cs。
您的 VS 解决方案将看起来像这样:

图 A2.1 – 项目骨架的解决方案资源管理器
我们现在准备好使用 TDD 编写第一个单元测试。
以 TDD 开始实现
在此刻,您可以打开您的ClientTests.cs并开始您的第一个测试,这将驱动库的架构。
我们希望将两个必需的参数lat和lon传递给一个 C#方法,我们将称之为OneCallAsync。这将生成一个带有正确查询字符串的 URL。因此,我们的单元测试类和第一个单元测试代码将开始成形,如下所示:
public class ClientTests
{
private const string ONECALL_BASE_URL =
"https://api.openweathermap.org/data/2.5/onecall";
private const string FAKE_KEY = "thisisafakeapikey";
private const decimal GREENWICH_LATITUDE = 51.4769m;
private const decimal GREENWICH_LONGITUDE = 0.0005m;
[Fact]
public async Task
OneCallAsync_LatAndLonPassed_UrlIsFormattedAsExpected()
{
// Arrange
var httpClient = new HttpClient();
var client = new Client(FAKE_KEY, httpClient);
// Act
var oneCallResponse = await
client.OneCallAsync(GREENWICH_LATITUDE,
GREENWICH_LONGITUDE);
// Assert
// will need access to the generated URL
}
}
由于 API 密钥需要与每个 API 调用一起发送,因此 API 密钥应在构造函数中,而不是方法参数的一部分。
重要提示
将 API 密钥放在构造函数中,将使类的消费者无需获取 API 密钥并将其传递给方法调用。相反,这将成为依赖注入设置的职责,以获取密钥,这更有意义。
我们肯定需要HttpClient类,因为您的客户端将使用 REST,这是您在.NET Core 中通常用于 RESTful 调用的。然而,在使用此类时,我们可能会面临以下挑战:
-
HttpClient是一个具体类,对其调用任何方法都会导致HttpClient向目标发出调用——这是默认行为,但可以进行微调。 -
HttpClient不提供对生成的 URL 的访问,这是我们当前测试想要的。
我们需要找出一种方法,在HttpClient调用目标(即实际的第三方服务)之前拦截电话,并获取生成的 URL 以进行检查。当然,我们还想消除出站调用,因为这是一个单元测试,我们不想真正调用第三方。
HttpClient可以在构造函数中传递一个HttpMessageHandler的实例,然后从HttpMessageHandler通过监视HttpMessageHandler.SendAsync获取生成的 URL,并消除真实调用。但是HttpMessageHandler是一个抽象类,所以我们不能实例化它;我们需要从它继承。
因此,让我们在您的单元测试项目中从 HttpMessageHandler 创建一个子类,并将其命名为 FakeHttpMessageHandler,如下所示:
public class FakeHttpMessageHandler : HttpMessageHandler
{
private HttpResponseMessage _fakeHttpResponseMessage;
public FakeHttpMessageHandler(
HttpResponseMessage responseMessage)
{
_fakeHttpResponseMessage = responseMessage;
}
protected override Task<HttpResponseMessage>
SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
=> SendSpyAsync(request, cancellationToken);
public virtual Task<HttpResponseMessage>
SendSpyAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
=> Task.FromResult(_fakeHttpResponseMessage);
}
我们创建了一个模拟类,这将使我们能够访问 HttpRequestMessage。现在,我们的 Arrange 将看起来像这样:
// Arrange
var httpResponseMessage = new HttpResponseMessage()
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{}")
};
var fakeHttpMessageHandler = Substitute.ForPartsOf
<FakeHttpMessageHandler>(httpResponseMessage);
HttpRequestMessage? actualReqMessage = null;
fakeHttpMessageHandler.SendSpyAsync(
Arg.Do<HttpRequestMessage>(x => actualReqMessage = x),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult(httpResponseMessage));
var fakeHttpClient = new
HttpClient(fakeHttpMessageHandler);
var client = new Client(FAKE_KEY, fakeHttpClient);
我们首先创建了一个响应消息,因此任何方法调用都将返回这个空对象。当我们运行实际代码时,这个对象将包含第三方响应。
重要提示
我们为 HttpMessageHandler 创建了一个模拟;我们也可以模拟它。两者都有效,这取决于个人偏好,哪个更易读。在这里,我觉得有一个模拟 HttpMessageHandler 更容易阅读。
此外,请注意,前面的实现可以被称为存根(而不是模拟),但我选择将其称为模拟,因为它包含了一些实际实现。有时存根和模拟之间只有一条细线。
注意,我们使用 NSubstitute 创建了对模拟的模拟。这样做的原因是我们想访问 HttpRequestMessage,它包含我们的最终 URL。
在这本书中,我们第一次使用 Substitute.ForPartsOf 而不是 Substitute.For,因为 For 不适用于具体类;代码可以编译,但您将得到运行时错误。
重要提示
我们一直使用 Substitute.For<ISomeInterface>,在 95%的情况下,您也会这样做。我们没有创建具体类的实例。对于没有接口的具体类,您将使用 ForPartsOf<SomeClass>。
我们的 Assert 部分变成了这样:
string actualUrl = actualHttpRequestMessage!.RequestUri!
.AbsoluteUri.ToString();
Assert.Contains(ONECALL_BASE_URL, actualUrl);
Assert.Contains($"lat={GREENWICH_LATITUDE}", actualUrl);
Assert.Contains($"lon={GREENWICH_LONGITUDE}", actualUrl);
现在,我们已经准备好编写生产代码了。
失败然后通过
由于我们没有创建生产代码,代码甚至无法编译,这将给我们想要的 TDD 失败。现在,我们将进行最小实现,以便通过测试:
public class Client
{
…
public async Task<OneCallResponse> OneCallAsync(
decimal latitude, decimal longitude)
{
const string ONECALL_URL_TEMPLATE = "/onecall";
var uriBuilder = new UriBuilder(
BASE_URL + ONECALL_URL_TEMPLATE);
var query = HttpUtility.ParseQueryString("");
query["lat"] = latitude.ToString();
query["lon"] = longitude.ToString();
query["appid"] = _apiKey;
uriBuilder.Query = query.ToString();
var _ = await _httpClient
.GetStringAsync(uriBuilder.Uri.AbsoluteUri);
return new OneCallResponse();
}
}
再次运行您的测试,它将通过。
我们为这个测试做了很多工作,但其他测试将很容易进行,因为它们将使用我们创建的相同模拟。让我们回顾一下我们所做的一切。
回顾
这里是所有重要活动的回顾,我们做了这些活动以使第一个测试通过:
-
我们想编写一个测试来检查 URL 是否正确形成。
-
我们不得不进入
HttpClient的内部以获取 URL。 -
HttpClient没有正确的方法来监视生成的 URL。 -
我们创建了一个继承自
HttpMessageHandler的模拟FakeHttpMessageHandler,并将其传递给HttpClient,这样我们就可以访问HttpClient的内部。 -
我们模拟了我们的模拟
FakeHttpMessageHandler并监视了 URL。 -
我们利用了 NSubstitute 的一个较少使用的方法来创建模拟,即
Substitute.ForPartsOf,这使得我们可以模拟一个具体类。 -
我们遵循了标准的 TDD 路线,即先失败后通过来实现我们的生产代码。
希望这使活动更加清晰。如果您对此有疑问,您可以查看完整的源代码。
在未来,您将遇到类似的复杂模拟场景,那么您将如何应对它们呢?
调查复杂的模拟场景
就像开发者生活中的一切一样,您总会找到其他人遇到过与您所面临的类似模拟场景。在网上搜索 access url HttpClient NSubstitute 就会为您提供快速解决问题的线索。
好消息是,大多数复杂的模拟问题已经得到解决,解决方案也已发布(感谢所有开发者的辛勤工作)。您只需要理解这个概念并将其融入您的解决方案中。
在本附录中,我们讨论了一个更高级但不太常见的模拟场景。这需要更多的调整和额外的工作,但有了模拟经验,您将熟悉这些场景,并且能够迅速解决它们。
进一步阅读
要了解更多关于本章讨论的主题,您可以参考 OpenWeather 的 官方网站:openweathermap.org




浙公网安备 33010602011771号