Java-测试驱动开发-全-

Java 测试驱动开发(全)

原文:zh.annas-archive.org/md5/2c12d0c2715833225165a83ae5e8566b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现代软件是由用户希望快速发布新功能、无缺陷的驱动,这无疑是一项极具挑战性的任务。独立开发者已经让位于共同协作开发单一软件产品的开发团队。功能以短周期迭代的方式添加,然后频繁地发布到生产环境中——有时甚至每天一次。

要实现这一点需要开发上的卓越。我们必须确保我们的软件始终准备好部署,并且在发布到生产环境中时没有缺陷。它必须易于我们的开发同事使用。代码必须易于任何人理解和修改。当团队进行这些更改时,我们必须有信心我们的新功能能够正常工作,并且我们没有破坏任何现有功能。

本书汇集了经过验证的技术,有助于将这一现实变为可能。

测试驱动开发TDD),SOLID 原则和六边形架构使开发者能够构建已知可以工作且易于使用的代码。开发重点在于软件工程的基本原理。这些实践是易于更改和安全的代码库背后的技术基础,并且始终准备好部署。

本书将使你能够编写出经过良好工程化和测试的代码。你将对自己的代码按预期工作有信心。你将拥有一个不断增长的快速运行的测试套件的安全感,在整个代码库中保持警惕,随着团队进行更改。你将学习如何组织代码以避免由外部系统(如支付服务或数据库引擎)引起的困难。你将减少对较慢测试形式的依赖。

你将能够编写出高质量的代码,适合持续交付的方法。

现代软件需要现代的开发方法。到这本书的结尾,你将掌握应用这些技术的技巧。

本书面向对象

本书主要面向熟悉 Java 语言基础且希望在高效敏捷开发团队中发挥作用的开发者。本书中描述的技术可以使你的代码在生产环境中以很少的缺陷交付,并且具有易于更改和安全的结构。这是敏捷的技术基础。

本书的前几章也将对希望在做出承诺之前了解这些方法成本和收益的企业领导者有所帮助。

本书涵盖内容

第一章构建 TDD 的案例,提供了对 TDD 带来的好处以及我们如何到达这里的理解。

第二章使用 TDD 创建优质代码,涵盖了一些有助于我们在应用 TDD 时创建良好工程化代码的一般良好实践。

第三章, 消除关于 TDD 的常见误解,是对我们可能遇到的关于使用 TDD 的常见反对意见的回顾,并提供了克服这些反对意见的建议。本章适合对将新技术引入开发过程可能有所保留的业务领导者。

第四章, 使用 TDD 构建应用程序,涉及设置我们的开发环境,使用 TDD 构建 Wordz 应用程序。它回顾了如何使用用户故事进行短期迭代工作。

第五章, 编写我们的第一个测试,介绍了使用 Arrange、Act 和 Assert 模板的基本 TDD。编写 Wordz 的第一个测试和生产代码,我们将详细探讨 TDD 如何在我们编写代码之前促进设计步骤。我们将考虑各种选项和权衡,然后在一个测试中捕捉这些决策。

第六章, 遵循 TDD 的节奏,展示了红、绿、重构周期作为开发节奏。我们决定编写下一个测试,观察它失败,让它通过,然后精炼我们的代码,使其在未来对团队来说既安全又简单。

第七章, 驱动设计 – TDD 与 SOLID,在前几章的基础上展示了 TDD 如何通过将 SOLID 原则纳入其中来提供对我们设计决策的快速反馈。SOLID 原则是一套有用的指导方针,有助于设计面向对象的代码。本章是对这些原则的回顾,以便我们可以在本书的其余部分应用它们。

第八章, 测试替身 – 模拟和存根,解释了两种关键技术,允许我们用更容易测试的事物替换难以测试的事物。通过这样做,我们可以将更多的代码置于 TDD 单元测试之下,减少我们对较慢的集成测试的需求。

第九章, 六边形架构 – 解耦外部系统,介绍了一种强大的设计技术,使我们能够完全解耦外部系统,如数据库和 Web 服务器,与我们的核心逻辑。在这里,我们将介绍端口和适配器的概念。这简化了 TDD 的使用,并且作为好处,对外部因素强加的任何变化都提供了弹性。

第十章, FIRST 测试与测试金字塔,概述了测试金字塔作为一种思考全面测试软件系统所需的不同类型测试的方法。我们讨论了单元测试、集成测试和端到端测试,以及每种类型之间的权衡。

第十一章TDD 如何融入质量保证,探讨了当使用本书中描述的高级测试自动化时,我们的 QA 工程师从一些可能不得不做的繁琐的详细测试中解放出来。本章探讨了测试现在是如何在整个开发过程中成为整个团队的共同努力,以及我们如何最好地结合我们的技能。

第十二章先测试,后测试,永不测试,回顾了基于我们何时编写测试以及我们确切测试什么的几种不同的测试方法。这将帮助我们提高在应用 TDD 时产生的测试质量。

第十三章驱动领域层,通过将 TDD、SOLID、测试金字塔和六边形架构应用于 Wordz 的领域层代码,进行了解释。这些技术的结合使我们能够将大部分游戏逻辑置于快速单元测试之下。

第十四章驱动数据库层,提供了编写连接到我们的 SQL 数据库 Postgres 的适配器代码的指导,现在我们已经将数据库代码从领域层解耦。我们这样做是先进行测试,使用 Database Rider 测试框架编写集成测试。数据访问代码使用 JDBI 库实现。

第十五章驱动网络层,作为本书的最后一章,解释了如何编写一个 HTTP REST API,使我们的 Wordz 游戏可以作为网络服务访问。这是通过使用集成测试来完成的,该测试是使用 Molecule HTTP 服务器库内置的工具编写的。完成这一步后,我们最终将整个微服务连接起来,准备作为一个整体运行。

为了充分利用本书

本书假设您了解基本的现代 Java,并可以使用类、JDK 8 lambda 表达式编写简短的程序,并使用 JDK 11 var关键字。它还假设您可以使用基本的git命令,从网络下载安装软件到您的计算机上,并对 IntelliJ IDEA Java IDE 有基本的了解。SQL、HTTP 和 REST 的基本知识将有助于本书的最后一章。

本书涵盖的软件/硬件 操作系统要求
Amazon Corretto JDK 17 LTS Windows, macOS, or Linux
IntelliJ IDEA 2022.1.3 Community Edition Windows, macOS, or Linux
JUnit 5 Windows, macOS, or Linux
AssertJ Windows, macOS, or Linux
Mockito Windows, macOS, or Linux
DBRider Windows, macOS, or Linux
Postgres Windows, macOS, or Linux
psql Windows, macOS, or Linux
Molecule Windows, macOS, or Linux
git Windows, macOS, or Linux

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

下载示例代码文件

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

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/kLcmS

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块应如下设置:

public class DiceRoll {
    private final int NUMBER_OF_SIDES = 6;
    private final RandomGenerator rnd =
                       RandomGenerator.getDefault();

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

public class DiceRoll {
    private final int NUMBER_OF_SIDES = 6;
    private final RandomGenerator rnd =
                       RandomGenerator.getDefault();

任何命令行输入或输出都应如下编写:

private final int NUMBER_OF_SIDES = 6

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要提示

看起来像这样。

联系我们

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

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

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

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。

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

分享您的想法

一旦您阅读了 使用 Java 进行测试驱动开发,我们非常乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的访问权限。

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

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

二维码

packt.link/free-ebook/978-1-80323-623-0

  1. 提交您的购买证明

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

第一部分:我们如何到达 TDD

第一部分 中,我们探讨了我们如何在软件行业中到达 TDD。我们试图用 TDD 解决什么问题?它创造了什么机会?

在以下章节中,我们将学习 TDD 为企业和技术人员带来的好处。我们将回顾优质代码的基本知识,以便我们在开始编写测试时有一个目标。了解团队有时会犹豫开始使用 TDD,我们将探讨六个常见的反对意见以及如何克服它们。

本部分包含以下章节:

  • 第一章构建 TDD 的案例

  • 第二章使用 TDD 创建优质代码

  • 第三章消除关于 TDD 的常见误解

第一章:构建 TDD 的案例

在我们深入探讨测试驱动开发TDD)是什么以及如何使用它之前,我们需要了解为什么我们需要它。每个经验丰富的开发者都知道,编写糟糕的代码比编写好的代码容易。即使是好的代码似乎随着时间的推移也会变得更糟。为什么?

在本章中,我们将回顾那些使源代码难以工作的技术失败。我们将考虑糟糕代码对团队和业务底线的影响。到本章结束时,我们将清楚地了解我们需要在我们的代码中避免的反模式。

在本章中,我们将讨论以下主要主题:

  • 恶劣的代码编写

  • 识别糟糕的代码

  • 降低团队绩效

  • 降低业务成果

恶劣的代码编写

每个开发者都知道,编写糟糕的代码似乎比编写好的代码容易得多。我们可以将好的代码定义为易于理解和安全更改的代码。因此,糟糕的代码是这种代码的对立面,它非常难以阅读代码并理解它试图解决的问题。我们害怕更改糟糕的代码——我们知道我们很可能会破坏某些东西。

我对糟糕代码的困扰可以追溯到我的第一个有意义的程序。这是一个为学校竞赛编写的程序,旨在帮助房地产经纪人帮助客户找到理想的房子。在学校使用的 8 位 Research Machines 380Z 计算机上编写,这是 1981 年对 Rightmove 的回应。

在那些互联网之前的年代,它作为一个简单的桌面应用程序存在,具有基于文本的绿色屏幕用户界面。它不需要处理数百万,更不用说数十亿的用户。它也不需要处理数百万的房屋。它甚至没有友好的用户界面。

作为一段代码,它是由几千行Microsoft Disk BASIC 9代码组成的。没有可说的代码结构,只有数千行带有不均匀行号和装饰着全局变量的代码。为了增加更大的挑战元素,BASIC 将每个变量限制为两个字母的名称。这使得代码中的每个名称都完全无法理解。源代码被有意写成尽可能少的空格,以节省内存。当你只有 32KB 的 RAM 来适应整个程序代码、数据和操作系统时,每个字节都很重要。

该程序仅向用户提供基本功能。用户界面是那个时代的,仅使用基于文本的表单。它比图形操作系统早了十年。程序还必须实现自己的数据存储系统,使用 5.25 英寸软盘上的文件。再次强调,可负担的数据库组件是未来的事情。该程序的主要功能是用户可以在特定的价格范围和功能集中搜索房屋。他们可以通过卧室数量或价格范围等术语进行筛选。

然而,代码本身真的非常混乱。请看以下原始列表的照片:

图 1.1 – 房地产中介代码列表

图 1.1 – 房地产中介代码列表

这种恐怖是其中一个开发版本的原始论文列表。正如你所见,它完全无法阅读。这不仅仅是你的问题。没有人能够轻易阅读它。我无法阅读,而且是我写的。我甚至可以说它是一团糟,我的混乱,是我一点一点敲击键盘创作的。

这类代码与工作噩梦无异。它不符合我们对于好代码的定义。阅读这样的列表并理解代码本应做什么并不容易。更改这样的代码是不安全的。如果我们尝试更改,我们会发现我们永远无法确定是否破坏了某些功能。我们还得手动重新测试整个应用程序。这将非常耗时。

谈到测试,我从未彻底测试过那段代码。一切都是手动测试,甚至没有遵循正式的测试计划。最多,我可能只进行了一小部分快乐路径的手动测试。这些测试旨在确认你可以添加或删除房屋,以及一些代表性的搜索功能正常工作,但仅此而已。我根本无法测试通过那段代码的每一条路径。我只是猜测它应该会工作。

如果数据处理失败,我就不知道发生了什么。我从未尝试过。每个可能的搜索组合都工作了吗?谁知道呢?我当然不知道。我甚至没有足够的耐心去完成所有那些繁琐的手动测试。它工作得足够好,足以赢得某种奖项,但代码仍然很糟糕。

理解为什么编写糟糕的代码

在我的情况下,这仅仅是知识不足。我不知道如何编写好的代码。但还有其他与技能无关的原因。没有人会故意编写糟糕的代码。开发者会尽他们所能,利用当时可用的工具,尽他们当时的能力去工作。

即使拥有正确的技能,一些常见问题也可能导致编写糟糕的代码:

  • 由于项目截止日期,没有时间对代码进行精炼

  • 与结构阻止新代码干净添加的遗留代码一起工作

  • 为紧急的生产故障添加短期修复,然后从未重新修改

  • 对代码主题领域的不熟悉

  • 对当地习语和发展风格的不熟悉

  • 不恰当地使用来自不同编程语言的习语

现在我们已经看到了一个难以使用的代码示例,并了解了它是如何产生的,让我们转向下一个明显的问题:我们如何识别糟糕的代码?

识别糟糕的代码

承认我们的代码难以使用是一回事,但要想超越这一点并编写好的代码,我们需要了解代码为什么是糟糕的。让我们确定技术问题。

糟糕的变量名

好的代码是自我描述的,并且可以安全地更改。糟糕的代码则不是。

名称是决定代码是否易于工作最关键的因素。好的名称清楚地告诉读者可以期待什么。不好的名称则不然。变量应该根据它们包含的内容来命名。它们应该回答“我为什么要使用这些数据?它会告诉我什么?

一个被命名为string的字符串变量命名不当。我们只知道它是一个字符串。这并没有告诉我们变量中有什么,或者我们为什么要使用它。如果那个字符串代表姓氏,那么通过简单地称之为surname,我们就帮助未来的代码读者更好地理解我们的意图。他们可以轻松地看到这个变量存储的是姓氏,并且不应该用于其他任何目的。

我们在图 1.1 中看到的两位字母变量名代表了 BASIC 语言的局限性。在当时,我们无法做得更好,但正如我们所见,它们并不有帮助。如果变量存储的是姓氏,那么理解sn的含义比surname要困难得多。更进一步,如果我们决定用一个名为x的变量来存储姓氏,那么我们给代码的读者带来了真正的困难。他们现在有两个问题要解决:

  • 他们必须逆向工程代码,以确定x是用来存储姓氏的。

  • 每次他们使用x时,都必须在心理上将x与姓氏的概念相对应。

当我们为所有数据使用描述性名称时,比如局部变量、方法参数和对象字段,事情就简单多了。在更一般的指导方针方面,以下 Google 风格指南是一个很好的资源:google.github.io/styleguide/javaguide.html#s5-naming

变量命名的最佳实践

描述包含的数据,而不是数据类型。

我们现在对如何命名变量有了更好的了解。现在,让我们看看如何正确地命名函数、方法和类。

不良的函数、方法和类名

函数、方法和类的命名都遵循类似的模式。在优秀的代码中,函数名告诉我们为什么我们应该调用该函数。它们描述了它们将为我们这些函数的用户做什么。重点是结果——函数返回时会发生什么。我们不描述该函数是如何实现的。这是很重要的。它允许我们在以后如果这样做变得有利时更改该函数的实现,并且名称仍然清楚地描述了结果。

一个名为calculateTotalPrice的函数清楚地说明了它将为我们做什么。它将计算总价。它不会有任何意外的副作用。它不会尝试做任何其他事情。它将做它所说的。如果我们把那个名字缩写为ctp,那么它就变得不那么清晰了。如果我们称之为func1,那么它告诉我们没有任何有用的信息。

恶劣的命名迫使我们每次阅读代码时都要重新设计每个决策。我们必须仔细阅读代码,试图找出它的用途。我们不应该不得不这样做。名字应该是抽象的。一个好的名字将通过将更大的理解浓缩成几个词来加快我们理解代码的能力。

你可以将函数名视为标题。函数内的代码是文本的主体。它的工作方式与你现在阅读的文本中的标题“识别糟糕的代码”相同,这给我们一个关于后续段落内容的一般概念。从标题中,我们期望段落是关于识别糟糕的代码,没有更多也没有更少。

我们希望能够通过其标题——函数、方法、类和变量名——快速浏览我们的软件,这样我们就可以专注于我们现在想做的事情,而不是重新学习过去做过的事情。

方法名与函数名处理方式相同。它们都描述了要执行的操作。同样,你可以将适用于函数名的相同规则应用于方法名。

方法名和函数名的最佳实践

描述结果,而不是实现过程。

再次强调,类名遵循描述性规则。一个类通常代表一个单一的概念,因此其名称应该描述该概念。如果类代表我们系统中的用户配置文件数据,那么UserProfile这个类名将帮助我们的代码读者理解这一点。

一个名字的长度取决于命名空间

以下是一些关于名字长度的进一步提示,适用于所有名字。名字应该是完全描述性的,但其长度取决于几个因素。当以下情况之一适用时,我们可以选择较短的名称:

  • 命名变量有一个小的作用域,只有几行

  • 类名本身提供了大部分的描述

  • 名字存在于某个其他命名空间中,例如类名

让我们通过每个情况的代码示例来使这一点更加清晰。

以下代码使用简短的变量名total计算一系列值的总和:

int calculateTotal(List<Integer> values) {
    int total = 0;
    for ( Integer v : values ) {
        total += v;
    }
    return total ;
}

这很好,因为很明显total代表所有值的总和。考虑到代码中的上下文,我们不需要一个更长的名字。也许一个更好的例子在于v循环变量。它有一个单行的作用域,在这个作用域内,很明显v代表循环中的当前值。我们可以使用一个更长的名字,如currentValue。然而,这增加了任何清晰度吗?实际上并没有。

在以下方法中,我们有一个名为gc的短参数名:

private void draw(GraphicsContext gc) {
    // code using gc omitted
}

我们可以选择如此简短的名字的原因是GraphicsContext类已经包含了大部分的描述。如果这是一个更通用的类,例如String,那么这种简短的名字技术将不会很有帮助。

在这个最后的代码示例中,我们使用了简短的方法名draw()

public class ProfileImage {
    public void draw(WebResponse wr) {
        // Code omitted
    }
}

这里类的名字非常具有描述性。我们系统中使用的 ProfileImage 类名通常用来描述用户个人资料页面上的头像或照片。draw() 方法负责将图像数据写入 WebResponse 对象。我们可以选择一个更长的方法名,比如 drawProfileImage(),但这只是重复了已经由类名明确的信息。这样的细节正是赋予了 Java 详尽声誉的原因,我觉得这是不公平的;往往是 Java 程序员而不是 Java 本身过于详尽。

我们已经看到,合理地命名事物可以使我们的代码更容易理解。让我们看看在糟糕的代码中我们看到的下一个大问题——使用那些使逻辑错误更可能发生的结构。

容易出错的构造

另一个坏代码的明显迹象是它使用了容易出错的构造和设计。在代码中做同样的事情总是有几种不同的方法。其中一些方法比其他方法更容易引入错误。因此,选择能够积极避免错误的编码方式是有意义的。

让我们比较两个不同的函数版本,用于计算总和值,并分析错误可能出现的地点:

 int calculateTotal(List<Integer> values) {
    int total = 0;
    for ( int i=0; i<values.size(); i++) {
        total += values.get(i);
    }
    return total ;
}

之前的列表是一个简单的函数,它将接受一个整数列表并返回它们的总和。这种代码自 Java 1.0.2 以来一直存在。它工作,但容易出错。为了使这段代码正确,我们需要做几件事情正确:

  • 确保将 total 初始化为 0 而不是其他值

  • 确保我们的 i 循环索引初始化为 0

  • 确保我们在循环比较中使用 < 而不是 <===

  • 确保我们将 i 循环索引递增正好一次

  • 确保我们将列表当前索引的值添加到 total

经验丰富的程序员确实往往一开始就能把这些事情都做对。我的观点是,出错的可能性是存在的。我见过错误,比如使用了 <= 而不是 <,结果代码因为 ArrayIndexOutOfBounds 异常而失败。另一个容易犯的错误是在累加总值的行中使用 = 而不是 +=。这只会返回最后一个值,而不是总和。我甚至犯过这样的错误,纯粹是因为打字错误——我确实以为我打对了,但我打得太快了,没有注意到。

显然,完全避免这类错误对我们来说要好得多。如果一个错误不可能发生,那么它就不会发生。这是一个我称之为 设计出错误 的过程。这是一个基本的代码整洁实践。为了了解我们如何将这个实践应用到之前的例子中,让我们看看以下代码:

int calculateTotal(List<Integer> values) {
    return values.stream().mapToInt(v -> v).sum();
}

这段代码做的是同样的事情,但本质上更安全。我们没有 total 变量,所以不能错误地初始化它,也不能忘记向它添加值。我们没有循环,所以没有循环索引变量。我们不能为循环结束使用错误的比较,因此不能得到 ArrayIndexOutOfBounds 异常。在这个代码实现中,出错的可能性大大减少。这通常也使得代码更容易阅读。这反过来又有助于新开发者的入职、代码审查、添加新功能和结对编程。

每当我们有选择使用更少部分且可能出错的地方时,我们应该选择那种方法。通过选择使我们的代码尽可能无错误和简单,我们可以让自己和我们的同事的生活变得更轻松。我们可以使用更健壮的结构来给错误更少的地方隐藏。

值得注意的是,代码的两个版本都存在整数溢出错误。如果我们相加的整数总和超出了允许的范围 -2147483648 到 2147483647,那么代码将产生错误的结果。然而,这个观点仍然成立:较晚的版本有更少的地方可能出错。结构上,这是一段更简单的代码。

现在我们已经看到了如何避免典型于糟糕代码的错误类型,让我们转向其他问题领域:耦合和内聚。

耦合与内聚

如果我们有许多 Java 类,耦合描述了这些类之间的关系,而内聚描述了每个类内部方法之间的关系。

当我们正确地处理耦合和内聚的数量时,我们的软件设计将更容易使用。我们将在第七章中学习帮助我们做到这一点的技术,驱动设计-TDD 和 SOLID。现在,让我们了解当我们做错时我们将面临的问题,从低内聚的问题开始。

类内部的低内聚

低内聚描述的是代码中许多不同的想法都聚集在同一个地方。下面的 UML 类图展示了具有低内聚方法的类的示例:

图 1.2 – 低内聚

图 1.2 – 低内聚

这个类中的代码试图结合太多的职责。它们并不都是明显相关的——我们正在向数据库写入,发送欢迎邮件,并渲染网页。这种大量的职责使得我们的类更难以理解,也更难以更改。考虑我们可能需要更改此类的不同原因:

  • 数据库技术的变更

  • 网页视图布局的变更

  • 网页模板引擎技术的变更

  • 邮件模板引擎技术的变更

  • 新闻生成算法的变更

我们有许多原因需要更改这个类中的代码。始终给类一个更精确的焦点会更好,这样就有更少的理由去更改它们。理想情况下,任何给定的代码片段只应该有一个需要更改的理由。

理解低内聚的代码很困难。我们被迫同时理解许多不同的想法。在内部,代码非常相互连接。改变一个方法通常会导致其他方法的改变,因为这种相互连接。使用这个类很困难,因为我们需要构建它时包含所有的依赖项。在我们的例子中,我们有一个模板引擎、数据库和创建网页的代码的混合。这也使得这个类非常难以测试。在我们可以对这个类运行测试方法之前,我们需要设置所有这些事情。这样的类重用性有限。这个类与它所包含的功能组合非常紧密地绑定在一起。

类之间的高耦合

高耦合描述的是在某个类可以使用之前,需要连接到几个其他类的情况。这使得它在独立使用时变得困难。在我们能够使用我们的类之前,我们需要确保那些支持类已经设置并正确工作。同样地,如果不理解它所具有的许多交互,我们就无法完全理解那个类。例如,以下 UML 类图显示了彼此之间具有高度耦合的类:

图 1.3 – 高耦合

图 1.3 – 高耦合

在这个虚构的销售跟踪系统示例中,有几个类需要相互交互。中间的User类与四个其他类耦合:InventoryEmailServiceSalesAppointmentSalesReport。这使得它比与其他类耦合较少的类更难以使用和测试。这里的耦合是否过高?也许不是,但我们可以想象出其他可以减少耦合的设计。最重要的是要意识到我们设计中类的耦合程度。一旦我们注意到与其他类有许多连接的类,我们就知道我们将面临理解、维护和测试它们的问题。

我们已经看到了高耦合和低内聚的技术元素是如何使我们的代码难以工作的,但糟糕的代码也有一个社会方面。让我们考虑一下糟糕的代码对开发团队的影响。

降低团队绩效

评估糟糕代码的一个好方法就是代码缺乏帮助其他开发者理解其功能的那些技术实践。

当你单独编码时,这并不那么重要。糟糕的代码只会让你慢下来,有时会让人感到有点泄气。它不会影响任何人。然而,大多数专业人士是在开发团队中编码的,这是一个完全不同的游戏。糟糕的代码真的会拖慢团队的速度。

关于这一点,以下两项研究很有趣:

第一项研究表明,开发者将高达 23%的时间浪费在糟糕的代码上。第二项研究表明,在 25%的与糟糕代码合作的情况下,开发者被迫进一步增加糟糕代码的数量。在这两项研究中,使用了技术债务这个术语,而不是直接指代糟糕的代码。这两个术语在意图上有所不同。技术债务是指为了满足截止日期而发布的已知技术缺陷的代码。它被跟踪和管理,目的是将来替换它。糟糕的代码可能存在相同的缺陷,但它缺乏有意性的救赎品质。

检入易于编写但难以阅读的代码真是太容易了。当我这样做的时候,我实际上对团队征收了一项税。下一个拉取我更改的开发者将不得不弄清楚他们到底需要做什么,而我糟糕的代码会让这一切变得更加困难。

我们都经历过这种情况。我们开始一项工作,下载最新的代码,然后只是长时间地盯着屏幕。我们看到一些没有意义的变量名,与纠缠不清的代码混合在一起,这些代码根本无法很好地解释自己。这对我们个人来说很令人沮丧,但在编程业务中,这确实有实际的成本。我们每浪费一分钟不理解代码,就意味着我们在浪费时间,而没有任何成果。这不是我们签上成为开发者时梦寐以求的事情。

糟糕的代码会干扰每一个必须阅读代码的未来开发者,甚至包括我们,原始的作者。我们忘记了我们之前的意思。糟糕的代码意味着开发者花费更多的时间修复错误,而不是增加价值。这意味着在生产环境中修复本应轻易预防的错误的更多时间被浪费了。

更糟糕的是,这个问题会加剧。它就像银行贷款的利息。如果我们留下糟糕的代码,下一个功能将涉及为糟糕的代码添加解决方案。你可能会看到额外的条件出现,给代码带来更多的执行路径,并为错误隐藏创造更多的地方。未来的功能建立在原始糟糕代码及其所有解决方案之上。它创建的代码中,我们阅读的大部分内容仅仅是围绕一开始就不好用的东西进行工作。

这种类型的代码耗尽了开发者的动力。团队开始花更多的时间解决问题,而不是在代码中增加价值。对典型的开发者来说,这一切都不是有趣的。对团队中的任何人都不是有趣的事情。

项目经理们失去了对项目进度的掌控。利益相关者对团队交付能力失去了信心。成本超支。截止日期推迟。功能被悄悄削减,只是为了在日程安排上稍微松一口气。每当新开发者看到糟糕的代码时,入职过程变得痛苦,甚至尴尬。

糟糕的代码让整个团队无法发挥他们应有的水平。这反过来又不会让开发团队感到快乐。除了不快乐的开发者之外,它还会对商业成果产生负面影响。让我们了解这些后果。

下降的商业成果

受到糟糕代码影响的不仅仅是开发团队。这对整个企业都是有害的。

我们可怜的用户最终为不工作的软件付费,或者至少是工作不正常的软件付费。糟糕的代码有无数种方式可以破坏用户的一天,无论是由于数据丢失、无响应的用户界面,还是任何类型的间歇性故障。这些中的每一个都可能是由一些微不足道的事情引起的,比如在错误的时间设置变量,或者在某个条件中的错误计算。

用户看不到任何这些,也看不到我们正确编写的数千行代码。他们只看到他们错过的付款,他们丢失的耗时两小时打出的文档,或者那个绝妙的最后机会票务交易,它根本就没有发生。用户对这类事情几乎没有耐心。这种类型的缺陷很容易让我们失去一个宝贵的客户。

如果我们很幸运,用户会填写一个错误报告。如果我们非常幸运,他们会告诉我们当时在做什么,并为我们提供重现错误的正确步骤。但大多数用户只是在我们应用的错误上点击删除。他们会取消未来的订阅并要求退款。他们会去评论网站,让全世界都知道我们的应用和公司是多么的无用。

到这个时候,这不仅仅是糟糕的代码;它已经成为一种商业责任。我们代码库中的失败和诚实的人类错误已经被人遗忘。相反,我们只是在一个充满负面情绪的竞争中来去的企业。

收入下降导致市场份额下降,净推荐者得分®™(NPS)降低,股东失望,以及所有让您的 C 级管理层在夜晚失眠的其他事情。我们的糟糕代码已经成为企业层面的问题。

这不是假设。已经发生了几起软件故障导致企业损失的事件。Equifax、Target 以及甚至 Ashley Madison 网站的网络安全漏洞都导致了损失。Ariane 火箭导致航天器和卫星有效载荷损失,总成本数十亿美元!即使是导致电子商务系统停机的轻微事件,也会很快导致成本上升,而消费者信任也会崩溃。

在每种情况下,失败可能只是相对较少的代码行中的小错误。当然,它们在某种程度上是可以避免的。我们知道人类会犯错误,而且所有软件都是由人类编写的,但可能只需要一点额外的帮助就能阻止这些灾难的发生。

早期发现失败的优势在以下图表中体现:

图 1.4 – 缺陷发现成本

图 1.4 – 缺陷发现成本

在前面的图中,缺陷修复的成本随着发现时间的延迟而增加:

  • 在代码之前通过失败的测试发现的:

发现缺陷最便宜、最快的方法是在编写生产代码之前为特性编写测试。如果我们编写了预期会使测试通过的生产代码,但测试却失败了,我们就知道我们的代码中存在问题。

  • 在代码之后通过失败的测试发现的:

如果我们为特性编写了生产代码,然后编写测试,我们可能会在我们的生产代码中发现缺陷。这发生在开发周期稍晚的时候。在发现缺陷之前,我们会浪费更多的时间。

  • 在手动 QA 过程中发现:

许多团队包括质量保证(QA)工程师。在开发者编写代码之后,QA 工程师将手动测试代码。如果在这里发现缺陷,这意味着自开发者首次编写代码以来已经过去了很长时间。需要进行返工。

  • 代码在生产环境中被最终用户发现:

这是最糟糕的情况。代码已经部署到生产环境中,最终用户正在使用它。一个最终用户发现了一个错误。错误必须被报告、分类,然后为开发安排修复,之后由 QA 重新测试,最后重新部署到生产环境。这是发现缺陷最慢且最昂贵的方法。

我们越早发现故障,我们纠正它所需的时间和金钱就越少。理想的情况是在我们甚至写下一行代码之前就有一个失败的测试。这种方法也有助于我们设计代码。我们越晚留出时间来发现错误,它给每个人带来的麻烦就越大。

我们已经看到低质量代码如何导致缺陷,对业务不利。我们越早检测到失败,对我们越好。将缺陷留在生产代码中既困难又昂贵,还会对我们的商业声誉产生负面影响。

摘要

我们现在可以从其技术特征中识别出糟糕的代码,并欣赏它给开发团队和业务结果带来的问题。

我们需要一种技术来帮助我们避免这些问题。在下一章中,我们将探讨 TDD 如何帮助我们交付干净、正确的代码,这是真正的商业资产。

问题和答案

  1. 只有工作代码就足够了吗?

很遗憾不是。满足用户需求的代码是专业软件的入门级步骤。我们还需要我们知道可以正常工作的代码,以及团队可以轻松理解和修改的代码。

  1. 用户看不到代码。为什么这对他们很重要?

这是真的。然而,用户期望事物能够可靠地工作,他们期望我们的软件能够持续更新和改进。这只有在开发者能够安全地使用现有代码时才可能实现。

  1. 写好代码更容易还是写坏代码更容易?

很不幸,编写好的代码要困难得多。好的代码不仅仅是工作正确,它还必须易于阅读、易于修改,并且对同事来说是安全的。这就是为什么 TDD 等技术扮演着重要的角色。我们需要尽可能多的帮助来编写有助于同事的整洁代码。

进一步阅读

第二章:使用 TDD 创建好代码

我们已经看到,糟糕的代码是坏消息:对商业不利,对用户不利,对开发者也不利。测试驱动开发TDD)是一种核心的软件工程实践,它帮助我们避免将糟糕的代码引入我们的系统。

本章的目标是了解 TDD 如何帮助我们创建经过良好工程设计的、正确的代码,以及它是如何帮助我们保持这种状态的。到本章结束时,我们将理解好代码背后的基本原理以及 TDD 如何帮助我们创建它。了解 TDD 为什么有效对我们来说很重要,这样我们可以激励自己,并且能够向同事们解释为什么我们也推荐他们使用它。

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

  • 设计高质量代码

  • 揭示设计缺陷

  • 预防逻辑缺陷

  • 防止未来缺陷

  • 记录我们的代码

设计高质量代码

高质量代码不是偶然发生的。它是故意的。它是成千上万个小决策的结果,每个决策都塑造了我们的代码的可读性、可测试性、可组合性和可变更性。我们必须在快速且草率的解决方案和更稳健的方法之间做出选择,后者让我们有信心,无论用户如何误用我们的代码,它都会按预期工作。

每一行源代码至少涉及这些决策中的一个。这需要我们做出大量的决策。

你会注意到我们之前没有提到 TDD。正如我们将看到的,TDD 不会为你设计代码。它不会移除将需求转化为代码所必需的工程敏感性和创造性输入。坦白说,我很感激这一点——这是我享受的部分。

然而,这确实会在 TDD 中导致很多早期失败,这是值得注意的。期望在不提供自己的设计输入的情况下实现 TDD 流程并获得高质量代码的想法将根本行不通。正如我们将看到的,TDD 是一种允许你对这些设计决策获得快速反馈的工具。你可以在代码仍然便宜且易于更改时改变主意并适应,但它们仍然是你的设计决策,正在发挥作用。

那么,什么是好的代码?我们追求的是什么?

对我来说,好的代码就是可读性。我追求清晰。我想通过编写清晰且易于工作的代码来善待未来的自己和那些长期忍受的同事们。我想创建清晰且简单的代码,其中不包含隐藏的陷阱。

虽然关于什么使代码好的建议有很多,但基本原理是直接的:

  • 说你所想,想你所言

  • 在私下里注意细节

  • 避免意外的复杂性

值得快速回顾一下我所说的这些内容。

说你所想,想你所言

这里有一个有趣的实验。取一段源代码(任何语言都可以),移除所有不属于语言规范的部分,然后看看你是否能弄清楚它是做什么的。为了使事情更加突出,我们将所有方法名称和变量标识符替换为符号???

这里有一个快速示例:

public boolean ??? (int ???) {
    if ( ??? > ??? ) {
        return ???;
    }
    return ???;
}

你有什么想法这个代码做什么吗?不,我也没有。我一点线索都没有。

我可以通过它的形状判断出它是一种某种评估方法,它接收一些内容并返回true/false。也许它实现了一个阈值或限制。它使用多路径返回结构,我们在检查某些内容后,一旦我们知道答案,就立即返回一个答案。

虽然代码的形状和语法告诉我们一些信息,但它并没有告诉我们太多。这绝对不够。我们关于代码做什么的几乎所有信息都是我们选择的自然语言标识符的结果。名称对于良好的代码至关重要。它们是至关重要的。它们是全部。它们可以揭示意图,解释结果,并描述为什么某些数据对我们很重要,但如果我们选择名称不当,它们就不能做到这些。

我使用两个命名指南,一个用于命名活动代码——方法和函数——另一个用于变量:

  • 方法 – 说明它做什么。结果是什么?我为什么要调用它?

  • 变量 – 说明它包含的内容。我为什么要访问它?

方法命名的一个常见错误是描述它内部是如何工作的,而不是描述结果是什么。一个名为addTodoItemToItemQueue的方法让我们承诺采用一种特定的方法实现,而我们并不真正关心。要么是这样,要么是误导信息。我们可以通过将其命名为add(Todo item)来改进名称。这个名称告诉我们为什么我们应该调用这个方法。它让我们有自由在以后修改它的编码方式。

变量命名的一个经典错误是说明它们是由什么构成的。例如,变量名String string对任何人都没有帮助,而String firstName清楚地告诉我这个变量是某人的名字。它告诉我为什么我想读取或写入这个变量。

也许更重要的是,它告诉我们不应该在那个变量中写什么。在同一个作用域内让一个变量承担多个功能确实是个头疼的问题。我经历过,已经做了,再也不想回头了。

结果表明代码就是讲故事,纯粹而简单。我们向人类程序员讲述我们正在解决的问题以及我们是如何决定解决它的。我们可以把任何旧代码扔进编译器,计算机就会让它工作,但如果我们想让人类理解我们的工作,我们必须更加小心。

在私下里注意细节

在私下里注意细节是描述计算机科学概念抽象信息隐藏的简单方法。这些是基本思想,使我们能够将复杂系统分解成更小、更简单的部分。

我认为抽象的方式与我请电工为我修理房子的方式相同。

我知道我的电热水器需要修理,但我不想知道如何修理。我不想学习如何修理。我不想弄清楚需要哪些工具并购买它们。我只想完全不参与这件事,只要在我需要的时候完成即可。所以,我会打电话给电工,让他们来修理。只要我不需要亲自做,我非常乐意支付一笔不错的费用。

这就是抽象的含义。电工抽象了我的热水器修理工作。复杂的任务会根据我的简单请求来完成。

抽象在优秀的软件中无处不在。

每当你使某种细节变得不那么重要时,你就进行了抽象。一个方法有一个简单的签名,但其中的代码可能很复杂。这是算法的抽象。一个局部变量可能被声明为String类型。这是对每个文本字符的内存管理和字符编码的抽象。一个将折扣券发送给一段时间未访问过我们网站的顶级客户的微服务是对业务流程的抽象。抽象在编程中无处不在,跨越所有主要范式——面向对象编程OOP)、过程式函数式

将软件拆分成组件的想法,每个组件都为我们处理一些事情,这是一个巨大的质量驱动因素。我们集中决策,这意味着我们不会在重复的代码中犯错误。我们可以独立彻底地测试一个组件。我们只需编写一次并拥有一个易于使用的接口,就可以通过编写它来设计出由难以编写的代码引起的问题。

避免意外的复杂性

这是我个人最喜欢的优秀代码的破坏者——那些根本不需要存在的复杂代码。

编写一段代码总是有无数种方法。其中一些使用复杂的特性或绕弯子;它们使用复杂的动作链来完成简单的事情。所有版本的代码都能得到相同的结果,但有些版本却意外地以更复杂的方式完成。

我对代码的目标是首先一眼就能看出我在解决什么问题,而将关于我是如何解决它的细节留给更深入的分析。这与我最初学习如何编码的方式大不相同。我选择强调领域而不是机制。这里的领域意味着使用与用户相同的语言,例如,用业务术语表达问题,而不仅仅是原始的计算机代码语法。如果我正在编写一个银行系统,我希望看到金钱、账簿和交易出现在最前面。代码所讲述的故事必须是关于银行的。

实现细节,如消息队列和数据库,虽然很重要,但仅限于它们描述我们今天是如何解决问题的。它们可能需要稍后更改。无论它们是否更改,我们仍然希望主要的故事是关于 交易进入账户,而不是 消息队列与 REST 服务通信

随着我们的代码越来越擅长讲述我们正在解决的问题的故事,我们使其更容易编写替换组件。用另一个供应商的产品替换数据库变得简单,因为我们确切地知道它在我们的系统中扮演着什么角色。

这就是我们所说的隐藏细节。在某种程度上,看到我们是如何连接数据库的很重要,但只有在我们看到为什么我们最初需要它之后。

为了给您一个具体的例子,这里有一段代码,它与我在生产系统中找到的一些代码类似:

public boolean isTrue (Boolean b) {
    boolean result = false;
    if ( b == null ) {
        result = false;
    }
    else if ( b.equals(Boolean.TRUE)) {
        result = true;
    }
    else if ( b.equals(Boolean.FALSE)) {
        result = false;
    }
    else {
        result = false;
    }
    return result;
}

您在这里可以看到问题。是的,确实需要这样的方法。这是一个低级机制,它将 Java 的 true/false 对象转换为等效的基本类型,并且是安全的。它涵盖了与 null 值输入相关的所有边缘情况,以及有效的 true/false 值。

然而,它有问题。这段代码很杂乱。它不必要地难以阅读和测试。它具有很高的 循环复杂度 (CYC)。CYC 是一个基于代码段中可能存在的独立执行路径数量的客观度量代码复杂性的指标。

之前的代码不必要地冗长且过于复杂。我相当肯定它有一个 else

从所需的逻辑来看,只有三个有趣的输入条件:nulltruefalse。它当然不需要所有那些 else/if 链来解码。一旦你处理完那个 null-to-false 转换,你实际上只需要检查一个值,然后你就可以完全决定返回什么。

更好的等效代码如下:

    public boolean isTrue (Boolean b) {
        return Boolean.TRUE.equals(b);
    }

这段代码以更少的麻烦做同样的事情。它不具有先前代码相同的偶然复杂性。它读起来更好。它更容易测试,需要测试的路径更少。它具有更好的循环复杂度指标,这意味着隐藏错误的地方更少。它更好地讲述了这个方法存在的原因。坦白说,我甚至可能会通过内联来重构这个方法。我不确定这个方法是否为实现添加了任何有价值的额外解释。

这个方法只是一个简单的例子。想象一下,如果把它扩展到数千行复制粘贴、略有变化的代码,您就能理解为什么偶然的复杂性是一个杀手。这种冗余随着时间的推移而积累,并以指数级增长。一切变得难以阅读,也难以安全地更改。

是的,我看到了。每当我看到它时,我都会感到非常难过。我们可以做得比这更好。作为专业的软件工程师,我们真的应该做到这一点。

本节对良好设计的基本原则进行了闪电般的巡礼。这些原则适用于所有编程风格。然而,如果我们能正确地做事,我们也能做错事。在下一节中,我们将探讨 TDD 测试如何帮助我们预防不良设计。

揭示设计缺陷

不良设计确实很糟糕。它是软件难以更改和难以工作的根本原因。你永远无法完全确定你的更改是否会起作用,因为你永远无法完全确定一个糟糕的设计真正在做什么。改变这种代码令人恐惧,通常会被推迟。整个代码段可能会被遗弃,只留下一个/* Here be dragons! */注释作为证明。

TDD 的第一个主要好处是它迫使我们考虑组件的设计。我们在考虑如何实现它之前就做这件事。通过按这种顺序做事,我们不太可能不小心陷入一个糟糕的设计。

我们首先考虑设计的方式是思考组件的公共接口。我们思考该组件将被如何使用,以及它将被如何调用。我们还没有考虑我们如何使任何实现真正工作。这是从外向内的思考。我们在考虑任何内部实现之前,先考虑代码从外部调用者的使用。

这对我们中的许多人来说是一种相当不同的方法。通常,当我们需要代码做某事时,我们首先编写实现。之后,我们将根据方法签名中需要的内容进行扩展,而不考虑调用点。这是从内向外的思考。当然,它有效,但它通常会导致复杂的调用代码。它使我们陷入不重要的实现细节中。

从外向内的思考意味着我们可以为用户梦想出完美的组件。然后,我们将调整实现以适应我们希望在调用点使用的代码。最终,这比实现本身更重要。这当然是抽象在实践中的应用。

我们可以提出以下问题:

  • 是否容易设置?

  • 是否容易要求它做某事?

  • 结果是否容易处理?

  • 是否难以以错误的方式使用它?

  • 我们对它是否做出了任何错误的假设?

通过提出正确的问题,我们可以得到正确的结果。

通过先编写测试,我们涵盖了所有这些问题。我们提前决定如何设置我们的组件,可能为对象决定一个清晰的构造函数签名。我们决定如何使调用代码看起来如何,以及调用点将是什么。我们决定如何消费任何返回的结果,或者它将对协作组件产生什么影响。

这就是软件设计的核心。TDD 不会为我们做这件事,也不会强迫我们做得很好。我们仍然可以为所有这些问题想出糟糕的答案,然后简单地编写一个测试来锁定那些糟糕的答案。我也在现实代码中多次看到这种情况发生。

TDD 提供了早期反思我们决策的机会。在我们甚至还没有考虑代码如何工作之前,我们实际上已经在为我们的代码编写第一个可执行调用点的第一个示例。我们完全专注于这个新组件将如何融入更大的画面中。

测试本身立即提供了关于我们的决策效果如何的反馈。它给出了三个明显的信号,表明我们可以也应该改进。我们将把细节留到后面的章节,但测试代码本身清楚地显示了当你的组件难以设置、难以调用或其输出难以处理时的情况。

分析在生成代码之前编写测试的好处

你可以在三个时间点选择编写测试:在代码之前,在代码之后,或者永远不写。

显然,从不编写任何测试会将我们带回到开发的黑暗时代。我们在盲目行动。我们编写代码假设它将工作,然后将其全部留给后续的手动测试阶段。如果我们幸运的话,我们将在客户之前发现功能错误。

在完成一小块代码后立即编写测试是一个更好的选择。我们得到了更快的反馈。我们的代码不一定更好,因为我们编写代码的心态与没有测试实现时的心态相同。同样类型的功能错误仍然存在。好消息是,我们将编写测试来揭示它们。

这是一项很大的改进,但这还不是黄金标准,因为它导致了一些微妙的问题:

  • 缺少的测试

  • 泄露的抽象

缺少的测试 – 未检测到的错误

缺少的测试是由于人类的天性造成的。当我们忙于编写代码时,我们的大脑中同时有很多想法。我们专注于特定的细节,而忽略了其他方面。我总是发现,在写完一行代码后,我心理上会很快地“继续前进”。我只是假设它将会没问题。不幸的是,当我开始编写测试时,这意味着我已经忘记了某些关键点。

假设我最终编写了一些这样的代码:

public boolean isAllowed18PlusProducts( Integer age ) {
    return (age != null)  && age.intValue() > 18;
}

我可能很快就开始了> 18的检查,然后心理上“继续前进”并记得年龄可能是null。我将添加And子句来检查它是否是或不是。这很有道理。我的经验告诉我,这段特定的代码需要做的不仅仅是进行基本的、健壮的检查。

当我编写测试时,我会记得编写一个测试来处理传入null的情况,因为这一点在我脑海中很清晰。然后,我将为更高的年龄编写另一个测试,比如21。再次,很好。

很可能我会忘记为年龄值为18的边缘情况编写测试。这里真的很重要,但我的注意力已经从那个细节转移开了。只需要同事的一条关于午餐的 Slack 消息,我很可能就会忘记那个测试,开始编写下一个方法。

前面的代码中有一个微妙的错误。它应该对任何18岁或以上的年龄返回true。它没有。它只对19岁或以上的年龄返回true。应该使用大于等于符号,但我忽略了这个细节。

不仅我在代码中忽略了细微差别,我还遗漏了一个重要的测试。我写了两个重要的测试,但我需要三个。

因为我自己写了其他测试,所以我对此没有任何警告。你没有得到一个你没有写的失败的测试。

我们可以通过为每段代码编写失败的测试,然后只添加足够的代码来使测试通过来避免这种情况。这种工作流程更有可能引导我们思考四个测试,以排除null处理和与年龄相关的三个边界情况。当然,它不能保证这一点,但它可以引导正确的思考方式。

泄漏的抽象——暴露无关的细节

泄漏的抽象是另一个问题。这是我们如此关注方法内部,以至于忘记了考虑我们的梦想调用点。我们只是随意地传播最容易编码的内容。

我们可能正在编写一个存储UserProfile对象的接口。我们可能先从代码开始,选择我们喜欢的JDBC库,编写方法,然后发现它需要一个数据库连接。

我们可以简单地添加一个Connection参数来解决这个问题:

interface StoredUserProfiles {
    UserProfile load( Connection conn, int userId );
}

初看之下,似乎没有什么大问题。然而,看看第一个参数:它是特定的JDBC连接对象。我们已经将我们的接口锁定为必须使用JDBC。或者至少,必须作为第一个参数提供一些与JDBC相关的东西。我们甚至没有打算这样做。我们只是没有彻底考虑。

如果我们考虑理想的抽象,它应该为给定的userId加载相应的UserProfile对象。它不应该知道它是如何存储的。不应该有特定的JDBC连接参数。

如果我们从外部考虑并先考虑设计再考虑实现,我们就不太可能走这条路。

这种泄漏的抽象会创建意外的复杂性。它们通过迫使未来的读者思考为什么我们坚持使用JDBC,而实际上我们从未打算这样做,使得代码更难理解。我们只是忘记设计它出来。

先编写测试有助于防止这种情况。它引导我们首先考虑理想的抽象,这样我们就可以为它们编写测试。

一旦我们编写了那个测试,我们就已经锁定了代码的使用决策。然后,我们可以找出如何在不泄露任何不想要的细节的情况下实现它。

之前解释的技术很简单,但涵盖了良好设计的绝大部分基础。使用清晰的名称。使用简单的逻辑。使用抽象来隐藏实现细节,这样我们就可以强调我们正在解决的问题,而不是我们是如何解决问题的。在下一节中,让我们回顾 TDD 最明显的益处:预防我们逻辑中的缺陷。

预防逻辑错误

当我们谈论测试时,逻辑错误的观念可能是每个人首先想到的:它**工作正确了吗

我在这里不能不同意——这真的很重要。就用户、收入、我们的 Net Promoter Score®™和市场增长而言,如果你的代码不能正确工作,它就不会销售。就是这么简单。

理解手动测试的局限性

我们从痛苦的教训中知道,最简单的逻辑错误往往是最容易创建的。我们可以联想到的例子是那些一次性的错误,比如从未初始化的变量中产生的NullPointerException,以及那些未在文档中提到的库抛出的异常。它们都是如此简单和微小。似乎对我们来说,意识到我们犯了这些错误应该是显而易见的,但我们都知道它们往往是最难发现的。当我们人类专注于代码的大局时,有时这些关键细节就会被人忽视。

我们知道手动测试可以揭示这些逻辑错误,但我们也知道从经验中得知手动测试计划是脆弱的。可能会遗漏步骤或匆忙中错过重要的错误。我们可能会简单地假设在这个版本中不需要测试这个代码部分,因为我们没有更改那个代码部分。你猜对了——这并不总是对我们有利。如果某些基本假设发生了变化,代码中的某些部分可能会出现与错误完全无关的 bug。

手动测试需要花钱,而这些钱现在不能用来添加闪亮的新功能。

手动测试还受到推迟发货日期的指责。现在,这对我们的手动测试同事来说是非常不公平的。显然,开发团队——显然没有编写 TDD 测试的代码——直到发货前只有几天时间,才会遇到自己的 bug。然后,我们将代码交给测试人员,他们必须在几乎没有时间的情况下运行大量的测试文档。他们有时会因推迟发布而受到责备,尽管真正的原因是开发时间比预期要长。

然而,我们从未真正发布过。如果我们把发布定义为包括经过测试的代码,我们应该这样做,那么很明显,必要的测试从未发生。如果你不知道代码是否工作,你就不应该道德地发布代码。如果你这样做,你的用户会很快提出投诉。

没有 wonder,一些测试同事在冲刺结束时变得如此烦躁。

通过自动化测试解决问题

TDD 完全解决了这个问题。这些逻辑错误根本不可能出现,这听起来像是一个幻想,但这是真的。

在你编写任何生产代码之前,你已经编写了一个失败的测试。一旦你添加了新的代码,你重新运行测试。如果你输入了逻辑错误,测试仍然会失败,你立刻就会知道。这里的魔法就在这里:你的错误发生了,但立刻就被突出显示出来。这使得你可以在它还新鲜在你的脑海中时修复它。这也意味着你不能忘记稍后修复它。

你通常可以直接找到出错的行并做出更改。这只需要 10 秒钟的工作,而不是等待几个月,直到测试隔离区开始工作并填写一个JIRA错误报告票。

我们所讨论的单元测试也运行得非常快——非常快。其中许多测试在毫秒内完成。与编写整个测试计划文档、运行整个应用程序、设置存储数据、操作用户界面UI)、记录输出,然后编写错误报告票相比,这要好得多,不是吗?

你可以看到这如何是一种消除错误的超级能力。我们在代码-测试-调试周期中节省了大量的时间。这降低了开发成本并提高了交付速度。这对我们的团队和用户来说都是巨大的胜利。

每次你在代码之前编写测试时,你都已经将错误排除在了那个代码之外。你遵循了最基本的规则,即不检查失败的测试代码。你使它们通过。

不需要说,你也不应该通过删除它、忽略它或使用某种技术手段使其“总是通过”来欺骗那个失败的测试。然而,我之所以说这些,是因为我确实在真实代码中看到了这种行为。

我们已经看到先编写测试如何帮助我们预防在新的代码中添加错误,但 TDD(测试驱动开发)甚至比这更好:它帮助我们预防在未来添加代码时引入错误,这一点我们将在下一节中讨论。

防御未来缺陷

当我们通过先编写测试来扩展代码时,我们总是可以简单地删除每个通过后的测试。我曾看到一些学生在教他们 TDD 时这样做,因为我还没有解释过我们还不应该这样做。无论如何,一旦测试通过,我们不会删除它们。我们保留所有测试。

测试逐渐发展成为大型回归测试套件,自动测试我们构建的代码的每个功能。通过频繁运行所有测试,我们在整个代码库中获得安全感和信心。

当团队成员向这个代码库添加功能时,保持所有测试通过表明没有人意外地破坏了某些东西。在软件中,你可能在某个地方添加了一个完全无辜的更改,结果发现一些看似无关的事情现在停止工作了。这将是由于我们之前没有理解的那两个部分之间的关系。

这些测试现在让我们对我们的系统和我们的假设有了更多的了解。它们防止了缺陷被写入代码库。这两者都是巨大的好处,但更大的图景是,我们的团队能够有信心安全地做出改变,并且知道有测试自动地照顾着它们。

这才是真正的敏捷,改变的自由。敏捷从来不是关于 JIRA 工单和冲刺。它始终是关于在不断变化的需求景观中快速、有信心地移动的能力。拥有数以万计的快速运行的自动化测试可能是我们最大的启用实践。

测试能够给予团队成员快速、有效地工作的信心,这是 TDD 的一个巨大好处。你可能听说过“快速行动,打破事物”的短语,这是来自 Facebook 早期的一个著名说法。TDD 允许我们快速行动,打破事物。

正如我们所看到的,测试在提供关于设计和逻辑正确性的快速反馈以及提供对未来错误的防御方面非常出色,但一个巨大的额外好处是,测试记录了我们的代码。

记录我们的代码

每个人都喜欢有帮助、清晰的文档,但不是当它过时且与当前代码库无关时。

在软件中有一个普遍的原则,即两个相关想法之间的分离越多,它们带来的痛苦就越大。作为一个例子,想想一些读取一些鲜为人知的文件格式的代码。只要你在读取那个旧格式的文件,一切都会正常工作。然后你升级了应用程序,那个旧文件格式不再被支持,一切都会崩溃。代码与那些旧文件中的数据内容分离了。文件没有改变,但代码改变了。我们甚至没有意识到发生了什么。

文档也是如此。最糟糕的文档通常包含在光泽最亮的生产中。这些是在代码创建很长时间后由具有不同技能集的团队编写的工件——文案写作、图形设计等等。当时间紧迫时,文档更新通常是首先被放弃的事情。

解决方案是将文档与代码更接近。让它由更接近代码、详细了解其工作原理的人来生成。让它由需要直接与该代码工作的人来阅读。

就像极限编程(XP)的所有其他方面一样,最明显的主要胜利是让它与代码如此接近,以至于它就是代码。这包括使用我们良好的设计原则来编写清晰的代码,我们的测试套件也扮演着关键角色。

我们的 TDD 测试是代码,而不是手动测试文档。它们通常与主代码库使用相同的语言和仓库编写。它们将由编写生产代码的同一个人编写——开发者。

测试是可执行的。作为一种文档形式,你知道可以运行的东西必须是最新的。否则,编译器会抱怨,代码将无法运行。

测试也完美地展示了如何使用我们的生产代码。它们清楚地定义了它应该如何设置,它有什么依赖项,它有哪些有趣的方法和函数,它的预期效果是什么,以及它将如何报告错误。关于该代码的任何你想知道的信息都在测试中。

起初可能会令人惊讶。测试和文档通常不会被混淆。由于 TDD 的工作方式,两者之间有很大的重叠。我们的测试是对我们的代码应该做什么以及我们如何让它为我们做到这一点的详细描述。

摘要

在本章中,我们了解到 TDD 帮助我们创建良好的设计,编写正确的逻辑,预防未来的缺陷,并为我们的代码提供可执行的文档。理解 TDD 对我们项目的作用对于有效地使用它以及说服我们的团队使用它非常重要。TDD 有很多优点,但在现实世界的项目中并没有像应该的那样经常使用。

在下一章中,我们将探讨一些常见的对 TDD 的反对意见,了解为什么它们不成立,以及我们如何帮助我们的同事克服它们。

问题和答案

  1. 测试和干净代码之间有什么联系?

没有直接的路径,这就是为什么我们需要了解如何编写干净的代码。TDD(测试驱动开发)增加价值的地方在于,它迫使我们在我们编写代码之前和最容易清理的时候去思考代码的用途。它还允许我们重构代码,改变其结构而不改变其功能,并确信我们没有破坏那个功能。

  1. 测试能否替代文档?

编写得好的测试可以替代一些但不是所有的文档。它们成为我们代码的详细和最新的可执行规范。它们无法替代的是用户手册、操作手册或公共应用程序编程****接口API)的合同规范等文档。

  1. 在测试之前编写生产代码有什么问题?

如果我们先编写生产代码,然后添加测试,我们更有可能面临以下问题:

  • 在条件语句上遗漏了破坏性的边缘情况

  • 通过接口泄露实现细节

  • 忘记重要的测试

  • 存在未测试的执行路径

  • 创建难以使用的代码

  • 在设计缺陷在后期过程中被揭示时,迫使进行更多的返工

进一步阅读

可以在维基百科链接中找到循环复杂性的正式定义。基本上,每个条件语句都会增加复杂性,因为它创建了一个新的可能的执行路径:

en.wikipedia.org/wiki/Cyclomatic_complexity

第三章:消除关于 TDD 的常见神话

测试驱动开发TDD)为开发人员和业务带来了许多好处。然而,它并不总是在实际项目中使用。这让我感到惊讶。TDD 已被证明在不同工业环境中改善了内部和外部代码质量。它适用于前端和后端代码。它适用于各个行业。我亲身体验过它在嵌入式系统、网络会议产品、桌面应用程序和微服务舰队中的工作。

为了更好地理解为什么人们的看法出了问题,让我们回顾一下对 TDD 的常见反对意见,然后探讨我们如何克服它们。通过理解感知到的困难,我们可以装备自己成为 TDD 的倡导者,并帮助我们的同事重新思考。我们将检查围绕 TDD 的六个流行神话,并形成对这些神话的建设性回应。

在本章中,我们将介绍以下神话:

  • “编写测试让我慢下来”

  • “测试不能防止每一个错误”

  • “你怎么知道测试是正确的”

  • “TDD 保证编写出好代码”

  • “我们的代码太复杂了,无法测试”

  • “我不知道要测试什么,直到我写代码”

编写测试让我慢下来

编写测试会减慢开发速度是关于 TDD 的一个常见抱怨。这种批评有一定的道理。就我个人而言,我始终认为 TDD 让我更快,但学术研究并不认同。美国计算机协会(Association for Computing Machinery)对 18 项主要研究进行的元分析表明,TDD 在学术环境中提高了生产力,但在工业环境中增加了额外的时间。然而,这并不是全部的故事。

理解放慢速度的好处

上述研究指出,使用 TDD 多花的时间的回报是减少进入软件的缺陷数量。使用 TDD,这些缺陷比其他方法更早地被识别和消除。通过在手动 质量保证QA)、部署和发布之前,以及在可能面对最终用户提出的错误报告之前解决这些问题,TDD 让我们能够削减大量浪费的努力。

我们可以从这张图中看到要完成的工作量的差异:

图 3.1 – 不使用 TDD 会因为返工而减慢我们

图 3.1 – 不使用 TDD 会因为返工而减慢我们

最上面一行表示使用 TDD 开发一个功能,我们有足够的测试来防止任何缺陷进入生产环境。最下面一行表示在没有 TDD 的情况下以 代码和修复 的风格开发相同的功能,并发现一个缺陷已经进入生产环境。没有 TDD,我们很晚才发现错误,让用户感到烦恼,并在返工上付出沉重的时间代价。请注意,代码和修复方案 看起来 让我们更快地进入 QA 阶段,直到我们考虑到所有由未发现的缺陷引起的返工。返工正是这个神话中没有考虑到的。

使用 TDD,我们只是将我们的设计和测试思考明确化并提前进行。我们通过可执行的测试来捕获和记录它。无论我们是否编写测试,我们仍然会花费相同的时间思考我们的代码需要覆盖的具体细节。结果证明,编写测试代码的机械性写作花费的时间非常少。当我们在第五章**,编写我们的第一个测试中编写第一个测试时,你可以亲自测量这一点。编写一段代码所花费的总时间是设计它的时间,加上编写代码的时间,再加上测试它的时间。即使没有编写自动化测试,设计和编码时间仍然是恒定和主导因素。

在所有这些中,方便地被忽视的一个领域是手动测试所需的时间。毫无疑问,我们的代码将被测试。唯一的问题是何时以及由谁进行测试。如果我们首先编写测试,那么就是由我们,即开发者进行。这发生在任何错误代码被检查到我们的系统之前。如果我们将测试留给手动测试同事,那么就会减慢整个开发过程。我们需要花时间帮助我们的同事理解我们代码的成功标准是什么。然后他们必须制定一个手动测试计划,这通常需要编写、审查和接受到文档中。

执行手动测试非常耗时。通常,整个系统必须构建并部署到测试环境中。数据库必须手动设置以包含已知数据。用户界面UI)必须点击以到达一个合适的屏幕,我们的新代码可能在那里被测试。输出必须手动检查,并对其正确性做出决定。每次我们做出更改时,都必须手动执行这些步骤。

更糟糕的是,我们越晚进行测试,就越有可能在存在任何错误代码的基础上构建。因为我们还没有测试我们的代码,所以我们无法知道我们在做这件事。这通常很难解开。在一些项目中,我们与主代码分支的差距如此之大,以至于开发者开始互相发送补丁文件。这意味着我们开始在这个错误代码的基础上构建,使其更难移除。这些是不良做法,但它们确实在真实项目中发生。

与先编写 TDD 测试相比,差异可谓天差地别。在 TDD 中,设置是自动化的,步骤被捕获并自动化,结果检查也是自动化的。我们谈论的是将手动测试的时间从分钟级减少到毫秒级,使用 TDD 单元测试。这种时间节省在每次我们需要运行那个测试时都会发生。

虽然手动测试不如 TDD 高效,但仍然有一个更糟糕的选择:完全没有测试。将缺陷发布到生产意味着我们将代码的测试留给用户。在这里,可能会有财务考虑和声誉损害的风险。至少,这是一个非常缓慢地发现错误的方式。从生产日志和数据库中隔离有缺陷的代码行是非常耗时的。根据我的经验,这通常也是令人沮丧的。

很有趣,一个永远找不到时间编写单元测试的项目总是能找到时间翻查生产日志、回滚发布代码、发布营销通讯,以及停止所有其他工作来进行优先级 1(P1)的修复。有时,感觉对于某些管理方法来说,找到一天的时间比找到一分钟的时间还容易。

TDD 确实在编写测试时 upfront 放置了时间成本,但作为回报,我们在生产中需要修正的错误更少——与多次返工周期中在实时代码中出现的缺陷相比,这大大节省了整体成本、时间和声誉。

克服测试让我们减速的反对意见

建立一个案例,追踪手动质量保证和失败部署中未发现的缺陷所花费的时间。找出最近一次现场问题修复所需的大致时间。找出哪些缺失的单元测试本可以预防它。现在计算一下编写这些测试需要多长时间。将这些数字呈现给利益相关者。计算出所有这些工程时间以及任何损失的收入可能更加有效。

知道测试在减少缺陷方面确实有整体益处,让我们来审视另一个常见的反对意见,即测试没有价值,因为它们不能预防每一个错误。

测试无法预防每一个错误

对任何类型的测试的一个非常古老的反对意见是:你不能捕捉到每一个错误。虽然这确实是真的,但如果我们从任何方面来看,这意味着我们需要更多和更好的测试,而不是更少。让我们了解这一点的动机,以便准备一个适当的回应。

理解为什么人们会说测试不能捕捉到每一个错误

立刻,我们可以同意这个说法。测试不能捕捉到每一个错误。更确切地说,已经证明软件系统中的测试只能揭示缺陷的存在。它永远不能证明不存在缺陷。我们可以有很多通过测试,但缺陷仍然可能隐藏在我们没有测试的地方。

这似乎也适用于其他领域。医学扫描并不总是能揭示那些难以察觉的问题。飞机的风洞测试并不总是能在特定的飞行条件下揭示问题。巧克力工厂的批量抽样不会捕捉到每一个不合格的甜食。

就因为我们无法捕捉到每个错误,并不意味着这使我们的测试无效。我们编写的每一个测试捕捉到一个缺陷,就会减少一个缺陷在我们的工作流程中运行。TDD 给我们提供了一个过程,帮助我们开发时考虑测试,但仍然有一些领域,我们的测试将不会有效:

  • 你没有想到要编写的测试

  • 由于系统级交互而产生的缺陷

我们没有编写的测试是一个真正的问题。即使在 TDD 中首先编写测试,我们也必须足够自律,为每个我们希望功能化的场景编写一个测试。编写测试然后编写代码使其通过很容易。诱惑是继续添加代码,因为我们正在顺利地进行。很容易忽略一个边缘情况,因此不为其编写测试。如果我们有一个缺失的测试,我们就会打开一个缺陷存在并被后来发现的可能。

这里系统级交互的问题指的是当你将经过测试的软件单元组合在一起时出现的行为。单元之间的交互有时可能比预期的更复杂。基本上,如果我们把两个经过良好测试的东西组合在一起,新的组合本身还没有经过测试。一些交互有错误,只有在这些交互中才会出现,尽管构成它们的单元已经通过了所有测试。

这两个问题是真实且有效的。测试永远无法覆盖所有可能的错误,但这却错过了测试的主要价值。我们编写的每一个测试都会减少一个缺陷。

通过不进行任何测试,我们永远不会发现任何错误。我们不会防止任何缺陷。如果我们测试,无论测试多少,我们都会提高代码的质量。这些测试可以检测到的每一个缺陷都将被防止。我们可以看到这个论点的稻草人性质:仅仅因为我们不能覆盖所有可能性,并不意味着我们不应该做我们能做的事情。

克服无法捕捉到每个错误的反对意见

重新审视这个问题的方式是我们对自己有信心,认为 TDD 可以防止许多类别的错误发生。当然,并不是所有类型的错误,但成千上万的测试库将显著提高我们应用程序的质量。

为了向我们的同事解释这一点,我们可以借鉴熟悉的类比:仅仅因为一个强大的密码不能阻止每个黑客,这并不意味着我们不应该使用密码,让自己容易受到任何和每个黑客的攻击。保持健康不能防止所有类型的医疗问题,但它可以防止许多严重的医疗问题。

最终,这是一个平衡的问题。零测试显然是不够的——在这种情况下,每一个缺陷最终都会上线。我们知道测试永远不能消除缺陷。那么,我们应该在哪里停止?什么构成了足够?我们可以争论说 TDD 帮助我们在这个最佳时机决定这个平衡:当我们思考编写代码的时候。我们创建的自动化 TDD 测试将为我们节省手动 QA 时间。这是不再需要做的手动工作。这些时间和成本节约在代码的每一次迭代中都会累积,并回报我们。

现在我们已经理解了为什么尽可能多的测试总是胜过完全不测试,我们可以看看下一个常见的反对意见:我们怎么知道测试本身是正确编写的?

你怎么知道测试是正确的?

这是一个有价值的反对意见,因此我们需要深入理解其背后的逻辑。这是来自不熟悉编写自动化测试的人的常见反对意见,因为他们误解了我们如何避免错误的测试。通过帮助他们看到我们实施的保障措施,我们可以帮助他们重新思考。

理解编写有缺陷的测试背后的担忧

你会听到的一个反对意见是,“如果测试本身没有测试,我们怎么知道测试是正确的呢?”这个反对意见是在我第一次向团队介绍单元测试时提出的。它是两极分化的。一些团队成员立刻理解了其价值。其他人无动于衷,但有些人则是积极敌对的。他们认为这种新的做法暗示他们有某种缺陷。这被视为一种威胁。在这种背景下,一位开发者指出了我解释的逻辑中的一个缺陷。

我告诉团队我们不能相信我们对生产代码的视觉阅读。是的,我们所有人都擅长阅读代码,但我们都是人类,所以我们会错过一些东西。单元测试可以帮助我们避免错过。一位聪明的开发者提出了一个很好的问题:如果视觉检查对生产代码不起作用,为什么我们说它对测试代码确实起作用?两者之间有什么区别?

对于这个问题的正确说明是在我需要测试一些 XML 输出之后(我记得那是 2005 年)。我编写的用于检查 XML 输出的代码确实很复杂。批评是正确的。我无法直观地检查那段测试代码,并诚实地说我没有发现任何缺陷。

因此,我将 TDD 应用于这个问题。我使用 TDD 编写了一个实用类,可以比较两个 XML 字符串并报告它们是否相同或第一个差异是什么。它可以配置为忽略 XML 元素的顺序。我将这段复杂的代码从原始测试中提取出来,并用对这个新实用类的调用替换了它。我知道这个实用类没有缺陷,因为它通过了我为它编写的每一个 TDD 测试。有很多测试,覆盖了我关心的每一个快乐路径和边缘情况。之前受到批评的原始测试现在变得非常简短和直接。

我让提出这个观点的同事审查了代码。他们同意,在这个新的、更简单的形式下,他们很高兴同意测试是正确的,从视觉上看。他们补充说,“如果工具类工作正常的话。”当然,我们有信心它通过了我们针对它编写的每一个 TDD 测试。我们确信它做了我们特别想要它做的事情,正如这些事情的测试所证明的那样。

提供我们测试测试的保证

这个论点的本质是,简短、简单的代码可以直观检查。为了确保这一点,我们保持大多数单元测试简单且足够简短,以便进行推理。当测试变得过于复杂时,我们将这种复杂性提取到自己的代码单元中。我们使用 TDD 来开发它,最终使得原始测试代码足够简单以便检查,测试工具足够简单以便其测试可以检查,这是一个经典的分而治之的例子。

实际上,我们邀请我们的同事指出他们认为我们的测试代码过于复杂而无法信任的地方。我们重构它,使用简单的工具类,这些工具类本身也是使用简单的 TDD 编写的。这种方法帮助我们建立信任,尊重同事的合理担忧,并展示了我们如何找到方法将所有 TDD 测试简化为简单、可审查的代码块。

既然我们已经解决了知道我们的测试是正确的问题,另一个常见的反对意见涉及对 TDD 过度自信:简单地遵循 TDD 过程就可以保证优秀的代码。这可能吗?让我们来分析一下这些论点。

TDD 保证优秀的代码

正如常常有对 TDD 过度悲观的反对意见一样,这里有一个相反的观点:TDD 保证 优秀的代码。因为 TDD 是一个过程,它声称可以改进代码,所以合理地假设使用 TDD 就是保证优秀代码的全部,这是完全正确的。不幸的是,这并不完全正确。TDD 帮助开发者编写优秀的代码,并且作为反馈,它可以帮助我们发现自己的设计和逻辑错误。然而,它并不能保证代码的优秀。

理解问题膨胀的期望

这里的问题是一个误解。TDD 不是一套直接影响你的设计决策的技术。它是一套帮助你指定你期望代码在何时、在什么条件下执行什么操作,以及给定特定设计的技术。它让你自由地选择设计,期望它做什么,以及如何实现这段代码。

TDD 没有关于选择长变量名还是短变量名的建议。它不会告诉你是否应该选择接口或抽象类。你应该将功能拆分到两个类还是五个类中?TDD 在这方面没有建议。你应该消除重复代码吗?反转依赖关系?连接到数据库?只有你可以决定。TDD 不提供建议。它不是智能的。它不能取代你和你的专业知识. 它是一个简单的流程,使你能够验证你的假设和想法。

管理你对 TDD 的期望

在我看来,TDD 非常有用,但我们必须在特定环境中看待它。它为我们提供决策的即时反馈,但将每个重要的软件设计决策留给我们。

使用 TDD,我们可以自由地使用SOLID原则(本书第七章第七章驱动设计——TDD 和 SOLID将涉及)编写代码,或者我们可以使用过程式方法、面向对象方法或函数式方法。TDD 允许我们根据需要选择算法。它使我们能够改变对如何实现某物的看法。TDD 适用于所有编程语言。它适用于每个垂直领域。

帮助我们的同事超越这个反对意见,使他们意识到 TDD 不是某种魔法系统,可以取代程序员的智慧和技能。它通过提供决策的即时反馈来利用这种技能。虽然这可能会让希望从思维不完善中产生完美代码的同事感到失望,但我们可以说 TDD 给我们思考的时间。优势在于它将思考和设计放在最前沿和中心。通过在编写使测试通过的生产代码之前编写失败的测试,我们确保我们已经考虑了代码应该做什么以及应该如何使用。这是一个巨大的优势。

既然我们明白 TDD 不会为我们设计代码,但仍然是开发者的朋友,我们如何处理复杂代码的测试?

我们的代码太复杂以至于无法测试

专业开发者通常要处理高度复杂的代码。这只是一个事实。这导致一个合理的反对意见:我们的代码太难编写单元测试了。我们正在处理的代码可能是非常有价值的、受信任的遗留代码,带来了显著的收入。这些代码可能很复杂。但是,它们是否“太”复杂以至于无法测试?是否可以说每一块复杂的代码都无法测试?

理解不可测试代码的原因

答案在于代码变得复杂和难以测试的三个原因:

  • 意外的复杂性:我们意外地选择了困难的方法而不是简单的方法

  • 外部系统无法控制以设置我们的测试

  • 代码纠缠得如此之紧,以至于我们不再理解它

意外的复杂性使得代码难以阅读和测试。最好的思考方式是知道任何给定问题都有许多有效的解决方案。比如说,我们想要加总五个数字。我们可以写一个循环。我们可以创建五个并发任务,每个任务处理一个数字,然后将该数字报告给另一个并发任务,该任务计算总和(请耐心等待,我会解释……我见过这种情况)。我们可以有一个基于复杂设计模式的系统,每个数字都会触发一个观察者,将每个数字放入一个集合中,然后触发一个观察者来增加总和,每 10 秒触发一次最后一个输入后的观察者。

是的,我知道其中一些很愚蠢。我只是编造了它们。但让我们说实话——你之前工作过哪些愚蠢的设计?我知道我写过比需要的更复杂的代码。

五个数加法示例的关键点是它实际上应该使用一个简单的循环。其他任何东西都是偶然的复杂性,既不必要也不故意。我们为什么会这样做?有许多原因。可能有项目约束、管理指令,或者仅仅是个人偏好影响了我们的决策。无论如何,一个更简单的解决方案是可能的,但我们没有采取。

测试更复杂的解决方案通常需要更复杂的测试。有时,我们团队认为不值得花时间在这上面。代码本身很复杂,编写测试会很困难,而且我们认为它已经可以工作了。我们认为最好不要去动它。

外部系统在测试中会引起问题。假设我们的代码与第三方网络服务进行交互。为这种情况编写可重复的测试是困难的。我们的代码消耗外部服务,并且它发送给我们的数据每次都不同。我们无法编写测试并验证服务发送给我们的内容,因为我们不知道服务应该发送什么。如果我们能用一个我们可以控制的虚拟服务替换那个外部服务,那么我们可以轻松解决这个问题。但如果我们的代码不允许这样做,那么我们就陷入了困境。

混乱的代码是这一点的进一步发展。为了编写测试,我们需要了解该代码对输入条件做了什么:我们期望输出是什么?如果我们有一段我们根本不理解代码,那么我们就无法为它编写测试。

虽然这三个问题都是真实的,但它们都有一个根本原因:我们让我们的软件陷入了这种状态。我们可以安排它只使用简单的算法和数据结构。我们可以隔离外部系统,这样我们就可以在没有它们的情况下测试其余的代码。我们可以模块化我们的代码,使其不会过于混乱。

然而,我们如何说服我们的团队接受这些想法呢?

重新审视良好设计和简单测试之间的关系

所有的前述问题都与制作工作软件但又不遵循良好设计实践有关。根据我的经验,改变这种情况最有效的方法是结对编程——在相同的代码上一起工作,并互相帮助找到更好的设计理念。如果结对编程不是一个选择,那么代码审查也可以提供一个检查点来引入更好的设计。结对编程更好,因为当你到达代码审查时,可能已经太晚做重大更改了。预防不良设计比纠正它更便宜、更好、更快。

没有测试管理遗留代码

我们会遇到没有测试的遗留代码,我们需要维护。通常,这段代码已经变得难以管理,理想情况下需要替换,但没有人知道它做什么了。可能没有书面文档或规范来帮助我们理解它。无论有什么书面材料,可能都是完全过时且无用的。代码的原始作者可能已经转到了不同的团队或不同的公司。

在这种情况下,最好的建议是尽可能让这段代码保持原样。然而,有时我们需要添加需要更改代码的功能。鉴于我们没有现有的测试,我们很可能发现添加一个新测试几乎是不可能的。代码根本就没有以这种方式拆分,以至于我们无法挂载测试。

在这种情况下,我们可以使用特征测试技术。我们可以将其描述为三个步骤:

  1. 运行遗留代码,向其提供所有可能的输入组合。

  2. 记录每次输入运行产生的所有输出。这个输出传统上被称为黄金大师。

  3. 编写一个特征测试,再次运行所有输入的代码。将每个输出与捕获的黄金大师进行比较。如果有任何不同的输出,则测试失败。

这个自动化测试将我们做出的任何代码更改与原始代码所做的内容进行比较。这将指导我们在重构遗留代码时的工作。我们可以使用标准重构技术结合 TDD。通过在黄金大师中保留有缺陷的输出,我们确保在这个步骤中我们纯粹是在重构。我们避免了在修复错误的同时重构代码的陷阱。当原始代码中存在错误时,我们分两个不同的阶段工作:首先,在不改变可观察行为的情况下重构代码。之后,将缺陷作为单独的任务修复。我们从不一起修复错误和重构。特征测试确保我们不会意外地将这两个任务混淆。

我们已经看到 TDD 如何帮助解决意外复杂性和更改遗留代码的困难。当然,在编写生产代码之前编写测试意味着我们需要在测试它之前知道代码的样子,对吧?让我们接下来回顾这个常见的反对意见。

我不知道要测试什么,直到我编写了代码

对于 TDD 学习者来说,一个巨大的挫折是知道要测试什么,而没有事先编写生产代码。这是另一个有价值的批评。在这种情况下,一旦我们理解了开发者面临的问题,我们就可以看到解决方案是一种我们可以应用到我们的工作流程中的技术,而不是思维方式的重新构建。

理解从测试开始遇到的困难

在一定程度上,考虑代码的实现方式是自然的。毕竟,这是我们学习的方式。我们编写 System.out.println("Hello, World!"); 而不是想出一个结构来围绕著名的行。当我们以线性代码编写时,类似于购物清单的指令,小型程序和实用程序运行得很好。

当程序变得更大时,我们开始面临困难。我们需要帮助将代码组织成可理解的块。这些块需要易于理解。我们希望它们是自我文档化的,并且我们能够轻松地知道如何调用它们。代码越大,这些块内部的细节就越不有趣,而这些块的外部结构——即“外部”——就越重要。

例如,假设我们正在编写一个 TextEditorWidget 类,并希望即时检查拼写。我们找到一个包含 SpellCheck 类的库。我们并不太关心 SpellCheck 类是如何工作的。我们只关心如何使用这个类来检查拼写。我们想知道如何创建该类的对象,需要调用哪些方法来使其执行拼写检查任务,以及我们如何访问输出。

这种思维方式是软件设计的定义——组件如何组合在一起。如果我们想要维护它们,那么在代码库增长时强调设计是至关重要的。我们使用封装来隐藏函数和类内部数据结构和算法的细节。我们提供了一个简单易用的编程接口。

克服先编写生产代码的需求

TDD 框架设计决策。通过在编写生产代码之前编写测试,我们定义了想要如何创建、调用和使用待测试的代码。这有助于我们迅速了解我们的决策效果如何。如果测试显示创建我们的对象很困难,这表明我们的设计应该简化创建步骤。如果对象难以使用,我们也应该简化我们的编程接口。

然而,当我们根本不知道合理的设计应该是什么样子时,我们该如何应对呢?当我们使用新的库、与团队其他部分的某些新代码集成或处理大型用户故事时,这种情况很常见。

为了解决这个问题,我们使用一个“spike”,一段足以证明设计形状的简短代码。在这个阶段,我们并不追求最干净的代码。我们不涵盖许多边缘情况或错误条件。我们有一个具体且有限的目的是探索对象和函数的可能排列,以形成一个可信的设计。一旦我们有了这个,我们就绘制一些关于设计的笔记,然后删除它。现在我们知道了一个合理的设计看起来是什么样子,我们就更有能力知道要编写哪些测试。现在我们可以使用正常的 TDD 来驱动我们的设计。

有趣的是,当我们以这种方式重新开始时,我们往往能得出比我们的 spike 更好的设计。TDD 的反馈循环帮助我们找到新的方法和改进。

我们已经看到,在测试之前开始实现代码是多么自然,以及我们如何可以使用 TDD 和 spike 来创建更好的流程。我们在最后责任时刻做出决定——在我们意识到做出不可逆转、较差的决定之前,可能做出的最新决定。当有疑问时,我们可以通过使用spike——一段旨在学习和然后丢弃的实验性代码——来了解更多关于解决方案空间的信息。

摘要

在本章中,我们学习了六个常见的神话,这些神话阻止团队使用 TDD,并讨论了重新构建这些对话的正确方法。TDD 在现代软件开发中的应用范围远远超过现在。这并不是说技术不起作用。TDD 只是有一个形象问题,通常是在没有体验过其真正力量的人中。

在本书的第二部分,我们将开始将 TDD 的各种节奏和技术付诸实践,并构建一个小型 Web 应用程序。在下一章中,我们将从使用Arrange-Act-AssertAAA模式编写单元测试的基本知识开始我们的 TDD 之旅。

提问和回答

  1. 为什么人们认为 TDD 会减慢开发者的速度?

当我们不编写测试时,我们节省了编写测试所花费的时间。这没有考虑到在生产中找到、重现和修复缺陷的额外时间成本。

  1. TDD 是否会消除人类的设计贡献?

不。恰恰相反。我们仍然使用我们所能使用的每一种设计技术来设计我们的代码。TDD 给我们的是关于我们的设计选择是否导致了易于使用、正确的代码的快速反馈循环。

  1. 为什么我的项目团队不使用 TDD?

提出这样的问题真是太棒了!真的。看看他们是否有任何反对意见被本章涵盖。如果是这样,你可以利用本章提出的思想温和地引导对话。

进一步阅读

更详细地介绍 Characterization Test 技术,其中我们以原样捕获现有软件模块的输出,目的是在不改变其任何行为的情况下重构代码。这在原始需求变得不明确或经过多年演变而包含其他系统现在依赖的缺陷的旧代码中特别有价值。

深入探讨在最后责任时刻做出决定对软件设计意味着什么。

第二部分:TDD 技术

第二部分介绍了有效 TDD 所需的技巧。在这个过程中,我们将逐步构建一个单词猜谜游戏 Wordz 的核心逻辑,首先编写所有测试。

到这部分结束时,我们将通过先编写测试来生成高质量的代码。SOLID 原则和六边形架构将帮助我们组织代码成为易于测试的精心设计的构建块。测试替身将外部依赖项置于我们的控制之下。我们将探讨测试自动化的更大图景,以及测试金字塔、质量保证工程师和工作流程如何改善我们的工作。

本部分包含以下章节:

  • 第四章, 使用 TDD 构建应用程序

  • 第五章, 编写我们的第一个测试

  • 第六章, 遵循 TDD 的节奏

  • 第七章, 驱动设计 – TDD 和 SOLID 原则

  • 第八章, 测试替身 – 模拟和存根

  • 第九章, 六边形架构 – 解耦外部系统

  • 第十章, FIRST 测试和测试金字塔

  • 第十一章, TDD 如何融入质量保证

  • 第十二章, 先测试,后测试,永不测试

第四章:使用 TDD 构建应用程序

我们将通过构建应用程序测试来学习 TDD 的实践方面。在构建过程中,我们还将使用一种称为 敏捷软件开发 的方法。敏捷意味着我们将以小而自包含的迭代方式构建软件,而不是一次性构建所有内容。这些小步骤使我们能够在进行中更多地了解软件设计。随着时间的推移,我们会根据对良好设计的信心进行设计调整和优化。我们可以在应用程序完成之前向早期测试用户提供可工作的功能,并在此期间收到他们的反馈。这是非常有价值的。正如我们在前面的章节中看到的,TDD 是提供对自包含软件组件快速反馈的绝佳方法。它是敏捷开发的完美补充。

为了帮助我们以这种方式构建,本章将介绍 用户故事 技巧,这是一种适合敏捷方法的捕获需求的方式。在描述我们的应用程序将做什么之前,我们将准备我们的 Java 开发环境,以便进行测试优先开发。

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

  • 介绍 Wordz 应用程序

  • 探索敏捷方法

技术要求

本章的最终代码可以在 github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter04 找到。

要一起编码——我强烈推荐这样做——我们首先需要设置我们的开发环境。这将使用优秀的 JetBrains IntelliJ Java 集成开发环境IDE),来自 Amazon 的免费 Java SDK 以及一些库来帮助我们编写测试并将库包含在我们的 Java 项目中。我们将在下一节中组装所有开发工具。

准备我们的开发环境

对于这个项目,我们将使用以下工具:

  • IntelliJ IDEA IDE 2022.1.3(社区版)或更高版本

  • Amazon Corretto Java 17 JDK

  • JUnit 5 单元测试框架

  • AssertJ 流畅断言框架

  • Gradle 依赖关系管理系统

我们将首先安装我们的 Java IDE,即 JetBrains IntelliJ IDE 社区版,然后再添加其余的工具。

安装 IntelliJ IDE

为了帮助我们与 Java 源代码一起工作,我们将使用 JetBrains IntelliJ Java IDE,使用其免费的社区版。这是一个在软件行业中流行的 IDE——而且有很好的理由。它将出色的 Java 编辑器与自动完成和代码建议相结合,以及调试器、自动重构支持、Git 源代码控制工具和出色的测试运行集成。

要安装 IntelliJ,请参阅以下步骤:

  1. 前往 www.jetbrains.com/idea/download/

  2. 点击您操作系统的标签页。

  3. 滚动到 社区 部分。

  4. 按照您操作系统的安装说明进行操作。

完成后,IntelliJ IDE 应已安装在您的计算机上。下一步是创建一个空白的 Java 项目,使用 Gradle 包管理系统,然后设置我们希望使用的 Java 版本。Mac、Windows 和 Linux 的安装通常很简单。

设置 Java 项目和库

一旦安装了 IntelliJ,我们就可以导入随附 GitHub 仓库中提供的起始项目。这将设置一个使用 Amazon Corretto 17 Java 开发工具包JDK)、JUnit 5 单元测试运行器、Gradle 构建管理系统和 AssertJ 流畅断言库的 Java 项目。

要完成此操作,请参阅以下步骤:

  1. 在你的网络浏览器中,访问github.com/PacktPublishing/Test-Driven-Development-with-Java

  2. 使用你喜欢的git工具在你的计算机上克隆整个仓库。如果你使用git命令行工具,这将如下所示:

git clone https://github.com/PacktPublishing/Test-Driven-Development-with-Java.git

  1. 启动 IntelliJ。你应该看到欢迎屏幕:

图 4.1 – IntelliJ 欢迎屏幕

图 4.1 – IntelliJ 欢迎屏幕

  1. 点击我们刚刚克隆的仓库中的chapter04文件夹。点击以突出显示它:

图 4.2 – 选择代码文件夹

图 4.2 – 选择代码文件夹

  1. 点击打开按钮。

  2. 等待 IntelliJ 导入文件。你应该看到以下工作空间已打开:

图 4.3 – IntelliJ 工作空间视图

图 4.3 – IntelliJ 工作空间视图

现在我们已经设置了 IDE,包含了我们开始所需的所有内容的骨架项目。在下一节中,我们将描述我们将要构建的应用程序的主要功能,我们将在下一章开始构建它。

介绍 Wordz 应用程序

在本节中,我们将在查看我们将要使用的敏捷过程之前,从高层次上描述我们将要构建的应用程序。该应用程序称为 Wordz,它基于一个流行的猜词游戏。玩家试图猜测一个五字母单词。根据玩家猜测单词的速度来计分。玩家会收到每次猜测的反馈,以引导他们找到正确答案。我们将使用各种 TDD 技术在本书的剩余部分构建该应用程序的服务器端组件。

描述 Wordz 的规则

要玩 Wordz,玩家将有最多六次机会猜测一个五字母单词。每次尝试后,单词中的字母将按以下方式突出显示:

  • 正确位置的正确字母具有黑色背景

  • 错误位置的正确字母具有灰色背景

  • 不在单词中出现的错误字母具有白色背景

玩家可以利用这个反馈来做出更好的下一个猜测。一旦玩家正确猜出单词,他们就能获得一些分数。第一次正确猜测得 6 分,第二次正确猜测得 5 分,第六次和最后一次正确猜测得 1 分。玩家在各个回合中相互竞争,以获得最高分。Wordz 是一款既有趣又能温和锻炼大脑的游戏。

虽然构建用户界面超出了本书的范围,但看到一个可能的示例非常有帮助:

图 4.4 – Wordz 游戏

图 4.4 – Wordz 游戏

技术上,我们将为这个游戏创建后端 Web 服务组件。它将公开一个应用程序编程接口API),以便用户界面可以使用该服务,并在数据库中跟踪游戏状态。

为了专注于 TDD 的技术,我们将某些内容排除在我们的范围之外,例如用户身份验证和用户界面。当然,生产版本将包括这些方面。但为了实现这些功能,我们不需要任何新的 TDD 技术。

这种简单的设计将使我们能够通过典型 Web 应用的各个层次全面探索 TDD。

既然我们已经定义了我们要构建的内容,下一节将介绍我们将用于构建它的开发方法。

探索敏捷方法

在构建 Wordz 的过程中,我们将采用迭代方法,将应用程序构建为一系列用户可以使用的功能。这被称为敏捷开发。它非常有效,因为它允许我们更早、更规律地发布功能给用户。它允许我们作为开发者,在过程中更多地了解我们正在解决的问题以及良好的软件设计的外观。本节将比较敏捷开发与瀑布方法的优点,然后介绍一个名为用户故事的敏捷需求收集工具。

敏捷开发的前身被称为瀑布式开发。之所以这样称呼,是因为项目阶段像瀑布一样流动,每个阶段都完全完成之后才会开始下一个阶段。

在瀑布式项目中,我们将开发分为一系列连续的阶段:

  1. 收集需求

  2. 进行需求分析

  3. 创建完整的软件设计

  4. 编写所有代码

  5. 测试代码

理论上,每个阶段都完美执行,一切正常,没有问题。实际上,总是会有问题。

我们发现了一些遗漏的需求。我们发现设计文档不能完全按照它们所写的那样编码。我们发现设计中缺少部分。编码本身可能会遇到困难。最糟糕的是,最终用户直到最后都看不到任何可工作的软件。如果他们看到的东西不是他们所想的,我们就需要进行非常昂贵的更改和返工。

原因在于人类有有限的远见。尽管我们尽力,但我们无法准确预测未来。我可以坐在这里,手里拿着一杯热咖啡,准确地知道它将在二十分钟后变凉。但我无法告诉你三个月后的天气。我们预测未来的能力局限于短期时间框架,对于有明确因果关系的流程。

瀑布式开发在面对不确定性和变化时表现非常糟糕。它是围绕所有事物都可以提前知道和计划的概念设计的。更好的方法是接受变化和不确定性,使其成为开发过程的一个积极部分。这是敏捷开发的基础。其核心是一个迭代方法,其中我们选择一个用户关心的微小特性,然后完全构建该特性,让用户尝试。如果需要变更,我们进行另一轮开发迭代。当我们的开发过程积极支持变更时,变更的成本要低得多。

专业敏捷开发流程依赖于维护一个始终经过测试的单一代码库,它代表了我们软件到目前为至的最佳版本。这个代码库始终准备部署给用户。我们一次增加一个特性,在前进的过程中不断改进其设计。

如 TDD(测试驱动开发)等技术在此中扮演着重要角色,通过确保我们的代码设计良好且经过彻底测试。每次我们将代码提交到主分支时,我们已经知道它已经通过了许多 TDD 测试。我们知道我们对它的设计感到满意。

为了更好地支持迭代开发,我们选择了一种迭代技术来捕捉需求。这种技术被称为用户故事,我们将在下一节中对其进行描述。

阅读用户故事——规划的基石

由于开发是迭代的,并接受重构和重做,因此旧的需求指定方法不再适用。我们不再被事先确定的大量需求文档所服务。我们更倾向于一次捕捉一个需求,构建它,并从中学习。随着时间的推移,我们可以优先考虑用户想要的功能,并更多地了解良好设计将如何呈现。

通过敏捷技术,我们不需要提前知道未来;我们可以与用户一起发现它。

支持这一变化的是一种新的需求表达方式。瀑布式项目从一份完整的需求文档开始,正式详细地描述每个特性。完整的需求集合——通常有数千个——使用如“系统应…”这样的正式语言表达,然后通过软件系统变更的细节进行解释。在敏捷开发中,我们不希望以这种方式捕捉需求。我们希望遵循两个关键原则来捕捉它们:

  • 需求一次一个地单独呈现

  • 我们强调对用户的价值,而不是对系统技术影响

实现这一技术的被称为用户故事。Wordz 首个要解决的问题的用户故事如下所示:

图 4.5 – 用户故事

图 4.5 – 用户故事

用户故事的格式始终相同——它由三个部分组成:

  • 作为[使用该软件的人或机器],…

  • 我想要[从这个软件中得到特定的结果]…

  • …以便[完成一个重要的任务]。

这三个部分以这种方式编写是为了强调敏捷开发围绕着系统用户获得的价值。这些不是技术需求。它们不(实际上,必须不)指定解决方案。它们只是陈述系统哪个用户应该从中获得哪些有价值的成果。

第一部分总是以“作为…”开始。然后命名这个故事将改进的用户角色。这可以是任何用户——无论是人类还是机器——的系统用户。唯一不能是的是系统本身,例如,“作为系统。”这是为了在我们的用户故事中强制清晰思考;它们必须始终为系统的某个用户提供一些好处。它们永远不是目的本身。

以一个拍照应用为例,作为开发者,我们可能希望有一个技术活动来优化照片存储。我们可能会写一个故事,例如,“作为一个系统,我想压缩我的图像数据以优化存储。”而不是从技术角度出发,我们可以重新构架这个故事,以突出对用户的益处:“作为一个摄影师,我想快速访问我的存储照片,并最大化新照片的空间。”

我想…”部分描述了用户希望得到的结果。这部分总是使用用户术语来描述,而不是技术术语。再次强调,这有助于我们关注用户希望我们的软件为他们实现什么。这是捕捉需求最纯粹的形式。在这个阶段,我们并没有尝试提出任何实现方式。我们只是捕捉用户打算做什么。

最后的部分,“…以便…”,提供了上下文。在“作为…”部分描述了受益,在“我想…”部分描述了如何受益,而在“…以便…”部分描述了为什么需要这个功能。这形成了开发此功能所需的时间和成本的理由。它可以用来确定下一个要开发的功能的优先级。

这个用户故事是我们开始开发的地方。Wordz 应用程序的核心是其评估和评分玩家当前猜词的能力。看看这项工作将如何进行是值得的。

将敏捷开发与 TDD 结合

TDD 是敏捷开发的完美补充。正如我们在前面的章节中学到的,TDD 帮助我们改进设计并证明我们的逻辑是正确的。我们所做的一切都是为了尽快向用户提供无缺陷的软件,TDD 是实现这一目标的好方法。

我们将使用的流程对于敏捷 TDD 项目来说是典型的:

  1. 选择一个优先级高的用户故事。

  2. 仔细考虑一下要实现的设计目标。

  3. 使用 TDD 编写核心中的应用逻辑代码。

  4. 使用 TDD 编写代码以将核心连接到数据库。

  5. 使用 TDD 编写代码以连接到 API 端点。

这个过程会重复。它形成了在单元测试下编写核心应用程序逻辑的节奏,然后向外扩展应用程序,将其连接到 API 端点、用户界面、数据库和外部网络服务。以这种方式工作,我们在代码中保留了大量的灵活性。我们还可以快速工作,一开始就专注于应用程序代码最重要的部分。

摘要

我们已经学到了让我们能够迭代构建应用程序的关键思想,在每一步获得价值,并避免通常令人失望的“一开始就进行大量设计”的方法。我们可以阅读用户故事,这将驱动我们以小而明确的步骤构建我们的 TDD 应用程序。现在我们也知道了我们将用于构建应用程序的过程——使用 TDD 来获得彻底测试的、干净的代码中心,然后将其扩展到现实世界。

在下一章中,我们将开始我们的应用程序。我们将通过编写第一个测试并确保它通过来学习每个 TDD 测试的三个关键组件。

问题和答案

  1. 水晶球开发听起来应该能很好地工作——为什么它不呢?

如果我们在项目开始时就知道每个缺失的需求、每个用户变更请求、每个糟糕的设计决策以及每个编码错误,那么瀑布式开发会很好地工作。但人类的预见性有限,事先知道这些事情是不可能的。因此,瀑布项目永远不会顺利。在项目后期出现昂贵的变更——就在你没有时间处理它们的时候。

  1. 我们能否在不使用 TDD 的情况下进行敏捷开发?

是的,尽管那样,我们会错过我们在前几章中涵盖的 TDD 的优势。我们还使我们的工作变得更难。敏捷开发的一个重要部分是始终展示最新的工作代码。没有 TDD,我们需要在我们的流程中添加一个大的手动测试周期。这会显著减慢我们的进度。

进一步阅读

  • 精通 React 测试驱动开发,ISBN 9781789133417

如果你想为 Wordz 应用程序构建用户界面,使用流行的 React 网络 UI 框架是一个很好的方法。这本 Packt 书籍是我个人的最爱之一。它展示了如何将我们在服务器端使用的相同类型的 TDD 技术应用到前端工作中。它还以高度可读的方式解释了从零开始 React 开发。

  • 敏捷模型化系统工程食谱,ISBN 9781838985837

本书提供了如何编写有效的用户故事以及捕捉敏捷需求、建模和分析的其他有用技术的进一步细节。

第五章:编写我们的第一个测试

是时候我们深入本章,编写我们的第一个 TDD 单元测试了。为了帮助我们做到这一点,我们将学习一个简单的模板,帮助我们将每个测试组织成逻辑清晰、易于阅读的代码片段。在这个过程中,我们将学习一些我们可以用来使我们的测试有效的关键原则。我们将看到编写测试是如何迫使我们做出关于代码设计和其易用性的决策,在需要考虑实现细节之前。

在一些介绍这些技术的例子之后,我们将开始我们的 Wordz 应用程序,编写一个测试,然后再添加生产代码以使该测试通过。我们将使用流行的 Java 单元测试库 JUnit5 和 AssertJ 来帮助我们编写易于阅读的测试。

在本章中,我们将涵盖编写有效单元测试背后的以下主要原则:

  • 开始 TDD:安排-行动-断言

  • 定义一个好的测试

  • 捕获常见错误

  • 断言异常

  • 只测试公共方法

  • 从我们的测试中学习

  • 开始 Wordz – 我们的第一个测试

技术要求

本章的最终代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter05找到。

开始 TDD:安排-行动-断言

单元测试并不神秘。它们只是代码,是用你编写应用程序的相同语言编写的可执行代码。每个单元测试都是你想要编写的代码的第一个使用。它以与实际应用程序相同的方式调用代码。测试执行该代码,捕获我们关心的所有输出,并检查它们是否是我们预期的。因为测试以与实际应用程序完全相同的方式使用我们的代码,所以我们能够立即获得关于我们的代码使用难易程度的反馈。这听起来可能很显然,确实如此,但它是一个强大的工具,可以帮助我们编写干净和正确的代码。让我们看看一个单元测试的例子,并学习如何定义其结构。

定义测试结构

当我们做事情时,遵循模板总是有帮助的,单元测试也不例外。基于在 Chrysler Comprehensive Compensation Project 上进行的商业工作,TDD 的发明者 Kent Beck 发现单元测试有某些共同特征。这被总结为测试代码的推荐结构,称为安排-行动-断言AAA

AAA 的原始定义

AAA 的原始描述可以在 C2 wiki 中找到:wiki.c2.com/?ArrangeActAssert

为了解释每个部分的作用,让我们通过一个完成的单元测试来了解一个代码片段,我们想要确保用户名以小写形式显示:

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
public class UsernameTest {
    @Test
    public void convertsToLowerCase() {
        var username = new Username("SirJakington35179");
        String actual = username.asLowerCase();
        assertThat(actual).isEqualTo("sirjakington35179");
    }
}

首先要注意的是我们的测试类名:UsernameTest。这是我们代码读者故事讲述的第一部分。我们描述了我们要测试的行为区域,在这种情况下,是用户名。我们所有的测试,实际上是我们所有的代码,都应该遵循这种故事讲述方法:我们希望代码的读者理解什么?我们希望他们清楚地看到我们正在解决的问题以及解决该问题的代码应该如何使用。我们希望向他们展示代码是正确工作的。

单元测试本身是convertsToLowerCase()方法。再次强调,方法名描述了我们期望发生的事情。当代码成功运行时,用户名将被转换为小写。这些名称故意设计得简单、清晰且具有描述性。此方法具有来自JUnit5测试框架的@Test注解。该注解告诉 JUnit 这是一个它可以为我们运行的测试。

@Test方法内部,我们可以看到我们的Arrange-Act-Assert结构。我们首先安排让我们的代码能够运行。这涉及到创建所需的任何对象,提供所需的任何配置,以及连接任何依赖的对象和函数。有时,我们可能不需要这一步,例如,如果我们正在测试一个简单的独立函数。在我们的示例代码中,Arrange步骤是创建username对象并将名称提供给构造函数的行。然后它将对象存储在局部username变量中,以便使用。这是var username = new Username("SirJakington35179");测试方法体的第一行。

接下来是Act步骤。这是我们让被测试代码执行的部分——我们运行这段代码。这始终是对被测试代码的调用,提供任何必要的参数,并安排捕获结果。在示例中,String actual = username.asLowerCase();行是Act步骤。我们调用username对象的asLowerCase()方法。它不接受任何参数,并返回一个包含小写文本sirjakington35179的简单String对象作为结果。

完成我们的测试是最后的Assert步骤。assertThat(actual).isEqualTo("sirjakington35179");行是我们的Assert步骤。它使用了来自AssertJ流畅断言库的assertThat()方法和isEqualTo()方法。它的任务是检查从Act步骤返回的结果是否与我们的期望相符。在这里,我们正在测试原始名称中的所有大写字母是否已转换为小写。

这样的单元测试易于编写、易于阅读,并且运行非常快。许多这样的测试可以在 1 秒内完成。

JUnit库是 Java 行业的标准单元测试框架。它为我们提供了一种将 Java 方法标注为单元测试的手段,允许我们运行所有测试,并在如图所示的IntelliJ IDE 窗口中直观地显示结果:

图 5.1 – JUnit 测试运行器的输出

图 5.1 – JUnit 测试运行器的输出

我们在这里看到单元测试失败了。测试期望结果是sirjakington35179文本字符串,但相反,我们收到了null。使用 TDD,我们会完成足够的代码来使那个测试通过:

图 5.2 – JUnit 测试通过

图 5.2 – JUnit 测试通过

我们可以看到,我们对生产代码的更改使得这个测试通过了。它已经“变绿”了,用流行的术语来说。失败的测试被称为红色测试,而通过的测试则是绿色。这是基于在流行的 IDE 中显示的颜色,这些颜色又基于交通信号灯。看到所有这些红色测试的短迭代变为绿色,令人惊讶地令人满意,同时也增强了我们对工作的信心。这些测试通过迫使我们从结果倒推来工作,帮助我们专注于代码的设计。让我们看看这意味着什么。

从结果倒推

我们立刻注意到,使这个测试通过的实际代码并不重要。这个测试中的每一件事都是关于定义代码的期望。我们正在设定代码为什么有用以及我们期望它做什么的边界。我们没有以任何方式约束它是如何做到的。我们正在从外部视角看待代码。任何使我们的测试通过的实现都是可接受的。

这似乎是在学习使用 TDD 过程中的一个转折点。我们中的许多人通过首先编写实现来学习编程。我们思考代码将如何工作。我们深入研究了特定实现背后的算法和数据结构。然后,作为一个最后的想法,我们将所有这些包裹在一个可调用的接口中。

TDD 将这一过程颠倒过来。我们故意首先设计可调用接口,因为这是代码的用户将看到的内容。我们使用测试来精确地描述代码将如何设置,如何调用,以及我们可以期望它为我们做什么。一旦我们习惯了这种从外向内的设计,TDD 就会非常自然地跟随,并在几个重要方面提高我们的工作流程效率。让我们回顾一下这些改进是什么。

提高工作流程效率

这样的单元测试以几种方式提高了我们的开发效率。最明显的是,我们写的代码已经通过了一个测试:我们知道它是有效的。我们不必等待手动 QA 过程来发现缺陷,然后为将来的重工作出错误报告。我们现在就发现并修复了错误,在将它们发布到主源分支之前,更不用说用户了。我们已经为我们的同事记录了我们的意图。如果有人想知道我们的Username类是如何工作的,它就在测试中——如何创建对象,你可以调用哪些方法,以及我们期望的结果是什么。

单元测试为我们提供了一种在隔离状态下运行代码的方法。我们不再被迫重新构建整个应用程序,运行它,在我们的数据库中设置测试数据条目,登录用户界面,导航到正确的屏幕,然后视觉检查我们代码的输出。我们运行测试。就是这样。这允许我们执行尚未完全集成到应用程序主分支中的代码。这加快了我们的工作。我们可以更快地开始,花更多的时间开发手头的代码,并且花更少的时间在繁琐的手动测试和部署流程上。

另一个好处是,这种设计行为提高了我们代码的模块化。通过设计可以分小块测试的代码,我们提醒自己编写可以分小块执行的代码。这自 1960 年代以来一直是基本的设计方法,并且至今仍然像以前一样有效。

本节已经介绍了我们用来组织每个单元测试的标准结构,但这并不保证我们会编写一个好的测试。为了实现这一点,每个测试都需要具有特定的属性。FIRST原则描述了良好测试的特性。让我们学习如何应用这些原则。

定义良好的测试

就像所有代码一样,单元测试代码可以写得更好或更差。我们已经看到 AAA 如何帮助我们正确地构建测试结构,以及准确、描述性的名称如何讲述我们代码意图的故事。最有用的测试也遵循 FIRST 原则,并且每个测试使用一个断言。

应用 FIRST 原则

这是一组五个原则,使测试更加有效:

  • 快速

  • 隔离

  • 可重复

  • 自验证

  • 及时

单元测试需要快速,就像我们之前的例子一样。这对于测试驱动开发(TDD)尤为重要,因为我们希望在探索设计和实现时立即获得反馈。如果我们运行一个单元测试,即使只需要 15 秒来完成,我们很快就会停止频繁地运行测试。我们会退化到编写大块的生产代码而不进行测试,以便我们花更少的时间等待缓慢的测试完成。这与我们想要的 TDD 正好相反,所以我们努力保持测试快速。我们需要单元测试在 2 秒或更短时间内运行,理想情况下是毫秒级。即使是两秒也是一个相当高的数字。

测试需要彼此隔离。这意味着我们可以选择任何测试或任何测试组合,以任何顺序运行它们,并始终得到相同的结果。一个测试不应依赖于另一个测试在其之前运行。这通常是不写快速测试的迹象,因此我们通过缓存结果或安排步骤设置来补偿。这是一个错误,因为它会减慢开发速度,尤其是对我们同事的影响。原因是我们不知道测试必须运行的特定顺序。当我们单独运行任何测试时,如果它没有得到适当的隔离,它将作为假阴性而失败。这个测试不再告诉我们关于我们正在测试的代码的任何信息。它只告诉我们我们之前没有运行某个测试,但没有告诉我们可能是哪个测试。隔离对于健康的 TDD 工作流程至关重要。

可重复的测试对于 TDD 至关重要。无论何时我们用相同的生产代码运行测试,该测试都必须始终返回相同的通过或失败结果。这听起来可能很显然,但需要小心才能实现这一点。考虑一个检查返回 1 到 10 之间随机数的函数的测试。如果我们断言返回数字 7,这个测试只会偶尔通过,即使我们正确地编写了函数。在这方面,三个常见的痛苦来源是涉及数据库的测试、针对时间的测试以及通过用户界面的测试。我们将在第八章中探讨处理这些情况的技术,测试替身——存根模拟

所有测试都必须是自验证的。这意味着我们需要可执行的代码来运行并检查输出是否符合预期。这一步骤必须自动化。我们绝对不能将这一检查留给人工检查,比如将输出写入控制台,然后由人工对照测试计划进行检查。单元测试通过自动化获得了巨大的价值。计算机检查生产代码,使我们免于遵循测试计划的繁琐,避免了人工活动的缓慢,以及人为错误的可能性。

及时的测试是在最合适的时间编写的测试,以便发挥最大的作用。编写测试的理想时间是在编写使测试通过的代码之前。看到团队使用不那么有益的方法并不罕见。当然,最糟糕的方法是根本不编写任何单元测试,而依赖人工质量保证来发现错误。采用这种方法,我们无法获得任何设计反馈。另一种极端是让分析师提前为组件编写每个测试——或者甚至为整个系统编写测试——然后将编码作为一项机械练习。这也无法从设计反馈中学习。它还可能导致过度指定的测试,这些测试锁定了不良的设计和实现选择。许多团队开始编写一些代码,然后继续编写单元测试,从而错失了早期设计反馈的机会。这也可能导致未测试的代码和错误的边缘情况处理。

我们已经看到 FIRST 原则如何帮助我们专注于编写一个好的测试。另一个重要的原则是不要试图一次性测试太多。如果我们这样做,测试就会变得非常难以理解。一个简单的解决方案是每个测试用例写一个断言,我们将在下一节中介绍。

每个测试用例使用一个断言

当测试用例简短且具体时,它们提供的反馈最有用。它们就像显微镜一样作用于代码,每个测试用例突出显示我们代码的一个小方面。确保这一点发生的最佳方式是每个测试用例写一个断言。这防止我们在一个测试中处理太多内容。这专注于我们在测试失败期间收到的错误消息,并帮助我们控制代码的复杂性。这迫使我们进一步分解问题。

决定单元测试的范围

另一个常见的误解是单元测试中的单元的含义。单元指的是测试隔离本身——每个测试都可以被视为一个独立的单元。因此,被测试代码的大小可以有很大的变化,只要这个测试可以独立运行。

将测试本身视为单元统一了关于单元测试范围应该是什么的几个流行观点。通常,人们会说单元是最小的可测试代码块——一个函数、方法、类或包。所有这些都是有效选项。另一个常见的论点是单元测试应该是一个类测试——每个生产代码类一个单元测试类,每个生产方法一个单元测试方法。虽然这是一种常见的方法,但这通常不是最佳方法。它不必要地将测试的结构与实现的结构耦合在一起,使得代码在未来更难更改,而不是更容易。

单元测试的理想目标是覆盖一个外部可见的行为。这在代码库的几个不同尺度上适用。如果我们能避免操作外部系统,如数据库或用户界面,我们可以对整个用户故事进行单元测试,跨越多个类包。我们将在第九章中探讨如何做到这一点,六边形架构——解耦外部系统。我们通常还使用更接近代码细节的单元测试,只测试单个类的公共方法。

一旦我们根据我们希望代码拥有的设计编写了测试,我们就可以专注于测试的更明显方面:验证我们的代码是否正确。

捕捉常见错误

测试的传统观点是将其视为一个检查代码按预期工作的过程。单元测试在这方面表现卓越,并自动执行使用已知输入运行代码并检查预期输出的过程。由于我们都是人类,我们在编写代码时有时会犯错误,其中一些可能会产生重大影响。我们可以犯的几个常见简单错误,单元测试在捕捉这些错误方面表现卓越。最可能出现的错误如下:

  • 偏移量错误

  • 逆条件逻辑

  • 缺少条件

  • 未初始化的数据

  • 错误的算法

  • 破坏的相等性检查

例如,回到我们之前对小写用户名的测试,假设我们决定不使用String内置的.toLowerCase()方法来实现,而是尝试编写自己的循环代码,如下所示:

public class Username {
    private final String name;
    public Username(String username) {
        name = username;
    }
    public String asLowerCase() {
        var result = new StringBuilder();
        for (int i=1; i < name.length(); i++) {
            char current = name.charAt(i);
            if (current > 'A' && current < 'Z') {
                result.append(current + 'a' - 'A');
            } else {
                result.append( current );
            }
        }
        return result.toString() ;
    }
}

我们会立即发现这段代码是不正确的。测试失败,如下面的图所示:

图 5.3 – 常见的编码错误

图 5.3 – 常见的编码错误

这段代码的第一个错误是一个简单的“偏移量错误” – 输出中缺少第一个字母。这指向了初始化循环索引时的错误,但这段代码中还有其他错误。这个测试揭示了两个缺陷。进一步的测试将揭示另外两个。你能仅通过视觉检查就看出它们是什么吗?与使用自动化测试相比,分析这样的代码在头脑中需要更多的时间和努力吗?

断言异常

单元测试在测试错误处理代码方面表现出色。作为测试异常抛出的例子,让我们添加一个业务需求,即我们的用户名必须至少有四个字符长。我们考虑我们想要的设计,并决定如果名字太短就抛出一个自定义异常。我们决定将这个自定义异常表示为class InvalidNameException。以下是使用 AssertJ 的测试示例:

@Test
public void rejectsShortName() {
    assertThatExceptionOfType(InvalidNameException.class)
            .isThrownBy(()->new Username("Abc"));
}

我们可以考虑添加另一个测试,专门用来证明四个字符的名字被接受且没有抛出异常:

@Test
public void acceptsMinimumLengthName() {
    assertThatNoException()
            .isThrownBy(()->new Username("Abcd"));
}

或者,我们可能简单地决定这个显式测试是不必要的。我们可能通过其他测试隐式地覆盖它。添加这两个测试是一个好习惯,可以使我们的意图更加明确。

测试名称相当通用,以rejectsaccepts开头。它们描述了代码正在测试的输出结果。这允许我们稍后改变主意,关于错误处理机制,可能切换到其他方式来表示错误。

单元测试可以捕捉常见的编程错误并验证错误处理逻辑。让我们看看编写单元测试的一个主要原则,以便我们在实现方法时具有最大的灵活性。

只测试公共方法

TDD(测试驱动开发)的全部内容是测试组件的行为,而不是它们的实现。正如我们在上一节中的测试所看到的,有一个针对我们想要的行为的测试使我们能够选择任何能够完成工作的实现。我们关注的是重要的部分 – 组件做什么 – 而不是不那么重要的细节 – 它是如何做到的

在测试内部,这表现为调用公共类和包的公共方法或函数。公共方法是我们在更广泛的应用中选择的公开行为。类、方法或函数中的任何私有数据或辅助代码都保持隐藏。

开发者在学习 TDD 时常见的错误是,他们为了简化测试而将事物公开。抵制这种诱惑。这里的典型错误是将私有数据字段公开,以便使用公共 getter 方法进行测试。这削弱了该类的封装性。现在更有可能 getter 会被误用。未来的开发者可能会向其他类添加真正属于这个类的方法。我们的生产代码的设计很重要。幸运的是,有一种简单的方法可以保持封装性,而不会损害测试。

保持封装性

如果我们觉得需要为所有私有数据添加 getter,以便测试可以检查每个数据是否符合预期,那么通常将此视为值对象会更好。值对象是一个没有身份的对象。任何包含相同数据的两个值对象都被认为是相等的。使用值对象,我们可以创建另一个包含相同私有数据的对象,然后测试这两个对象是否相等。

在 Java 中,这要求我们为我们自己的类编写一个自定义的equals()方法。如果我们这样做,我们也应该编写一个hashcode()方法,因为这两个方法是相辅相成的。任何有效的实现都可以。我建议使用Apache commons3库,它使用 Java 反射功能来完成这项工作:

@Override
public boolean equals(Object other) {
    return EqualsBuilder.reflectionEquals(this, other);
}
@Override
public int hashCode() {
    return HashCodeBuilder.reflectionHashCode(this);
}

你可以在commons.apache.org/proper/commons-lang/了解更多关于这些库方法的信息。

简单地将这两种方法(以及Apache commons3库)添加到我们的类中,意味着我们可以保持所有数据字段私有,同时仍然检查所有字段是否包含预期的数据。我们只需创建一个新的对象,包含所有预期的字段,然后断言它与我们正在测试的对象相等。

当我们编写每个测试时,我们正在首次使用被测试的代码。这使我们能够了解很多关于我们的代码如何易于使用的信息,如果需要,我们可以进行更改。

从我们的测试中学习

我们的测试是我们设计反馈的丰富来源。当我们做出决策时,我们将它们编写为测试代码。看到这些代码——我们生产代码的第一次使用——将我们提出的设计的优劣清晰地聚焦起来。当我们的设计不好时,测试中的 AAA 部分将揭示这些设计问题作为代码异味。让我们详细尝试理解这些如何帮助识别有缺陷的设计。

一个混乱的 Arrange 步骤

如果我们的 Arrange 步骤中的代码很混乱,我们的对象可能难以创建和配置。它可能需要在构造函数中包含太多参数,或者在测试中留下太多可选参数为null。可能的情况是,对象需要注入太多依赖项,这表明它有太多的职责,或者它可能需要太多的原始数据参数来传递大量的配置项。这些都是我们创建对象的方式可能需要重新设计的信号。

一个混乱的 Act 步骤

在行为(Act)步骤中调用代码的主要部分通常很简单,但它可以揭示一些基本的设计错误。例如,我们可能有不清晰的参数,比如传递给列表的BooleanString对象。很难知道每个参数的含义。我们可以通过将这些难以理解的参数包装在一个易于理解的新类中来重新设计,这个新类被称为配置对象。另一个可能的问题是,如果行为步骤需要按特定顺序进行多次调用,那么这是容易出错的。很容易按错误的顺序调用它们或忘记其中一个调用。我们可以重新设计,使用一个包含所有这些细节的单个方法。

混乱的断言(Assert)步骤

断言步骤将揭示我们的代码结果是否难以使用。问题区域可能包括必须按特定顺序调用访问器,或者可能返回一些传统的代码异味,例如结果数组,其中每个索引都有不同的含义。在两种情况下,我们都可以重新设计,使用更安全的结构。

在这些情况中,我们的单元测试中的一个代码部分看起来是错误的 – 它有一个代码异味。这是因为我们正在测试的代码的设计有相同的代码异味。这就是单元测试对设计提供快速反馈的含义。它们是我们所写代码的第一个用户,因此我们可以早期识别问题区域。

现在我们已经有了开始编写我们示例应用程序的第一个测试所需的所有技术。让我们开始吧。

单元测试的局限性

一个非常重要的观点是,自动化测试只能证明缺陷的存在,而不能证明缺陷的不存在。这意味着,如果我们考虑一个边界条件,为它编写一个测试,并且测试失败,我们知道我们的逻辑中有一个缺陷。然而,如果所有测试都通过,这并不也不能意味着我们的代码没有缺陷。它只意味着我们的代码没有我们考虑测试的所有缺陷。根本不存在任何魔法解决方案可以确保我们的代码没有缺陷。TDD 在这方面给我们带来了很大的帮助,但我们绝不能仅仅因为所有测试都通过就声称我们的代码没有缺陷。这根本不是真的。

这的一个重要后果是,我们的质量保证(QA)工程同事的重要性与以往一样,尽管我们现在帮助他们从一个更容易的起点开始。我们可以向我们的手动 QA 同事提供 TDD 测试过的代码,并确保许多缺陷已被预防并证明不存在。这意味着他们可以开始进行手动探索性测试,找出我们从未想过要测试的所有事情。共同努力,我们可以利用他们的缺陷报告来编写进一步的单元测试,以纠正他们发现的问题。即使有 TDD,QA 工程师的贡献仍然至关重要。我们需要我们团队所能提供的所有帮助,以努力编写高质量的软件。

代码覆盖率 – 一个经常没有意义的指标

代码覆盖率是衡量在给定运行中执行了多少行代码的指标。它是通过代码插装来衡量的,这是代码覆盖率工具为我们做的事情。它通常与单元测试结合使用,以衡量在运行测试套件时执行了多少行代码。

理论上,你可以看到这可能会意味着可以通过科学的方式发现缺失的测试。如果我们看到有一行代码没有被运行,那么我们肯定在某个地方遗漏了一个测试。这是真的,也是有帮助的,但反过来则不然。假设我们在测试运行期间获得了 100%的代码覆盖率。这意味着软件现在已经完全测试并正确无误了吗?不。

考虑对if (x < 2)语句有一个单独的测试。我们可以编写一个测试,使这一行执行并包含在代码覆盖率报告中。然而,一个单独的测试不足以覆盖所有可能的行为。条件语句可能使用了错误的运算符——小于而不是小于等于。它可能使用了不正确的限制值 2,而应该是 20。任何单个测试都无法完全探索该语句中行为的所有组合。我们可以让代码覆盖率告诉我们这一行已经运行,并且我们的单个测试通过了,但我们仍然可能存在几个逻辑错误。我们可以有 100%的代码覆盖率,但仍然有缺失的测试。

编写错误的测试

来讲一个关于我的最佳 TDD 尝试如何彻底失败的个人故事。在一个计算个人税务报告的移动应用程序中,有一个特定的是/否复选框来指示你是否有过学生贷款,因为这将影响你支付的税款。在我们的应用程序中有六个后果,我彻底进行了 TDD 测试,并仔细编写了我的测试。

很遗憾,我误解了用户故事。我把每一个测试都颠倒了。原本复选框应该应用相关税款的,现在它没有应用,反之亦然。

幸运的是,我们的质量保证工程师注意到了这个问题。她唯一的评论是她在这个系统中找不到任何针对这个缺陷的解决方案。我们得出结论,TDD 在使代码做我想让它做的事情方面做得很好,但我没有很好地弄清楚那应该是什么。至少这是一个非常快速的修复和重新测试。

开始编写 Wordz

让我们将这些想法应用到我们的 Wordz 应用程序中。我们将从一个包含应用程序核心逻辑的类开始,这个类代表一个要猜测的单词,并且可以计算出猜测的得分。

我们首先创建一个单元测试类,这立即让我们进入了软件设计模式:我们应该怎么命名这个测试?我们将选择WordTest,因为这概述了我们想要覆盖的区域——要猜测的单词。

传统的 Java 项目结构分为包。生产代码位于 src/main/java 下,测试代码位于 src/test/java 下。这种结构描述了生产代码和测试代码是源代码中同等重要的部分,同时为我们提供了一个编译和部署仅生产代码的方法。当我们处理源代码时,我们总是将测试代码与生产代码一起发送,但对于部署的可执行文件,我们只省略测试。我们还将遵循基本的 Java 包约定,在顶级为我们的公司或项目命名一个唯一名称。这有助于避免与库代码冲突。我们将命名为 com.wordz,以应用名称命名。

下一个设计步骤是决定首先驱动和测试哪种行为。我们总是想要一个简单的快乐路径版本,这有助于驱动出最常执行的正常逻辑。我们可以稍后覆盖边缘情况和错误条件。首先,让我们编写一个测试,它将返回一个不正确的单个字母的分数:

  1. 编写以下代码以开始我们的测试:

    public class WordTest {
    
        @Test
    
        public void oneIncorrectLetter() {
    
        }
    
    }
    

测试的名称为我们提供了对测试正在做什么的概述。

  1. 为了开始我们的设计,我们决定使用一个名为 Word 的类来表示我们的单词。我们还决定将猜测的单词作为构造函数参数提供给我们要创建的 Word 类的对象实例。我们将这些设计决策编码到测试中:

    @Test
    
    public void oneIncorrectLetter () {
    
        new Word("A");
    
    }
    
  2. 在此阶段,我们使用自动完成功能在单独的文件中创建一个新的 Word 类。请确保在 src/main 文件夹树中,而不是 src/test

图 5.4 – 创建类对话框

图 5.4 – 创建类对话框

  1. 点击 OK 在源树中的正确包内创建文件。

  2. 现在,我们将 Word 构造函数参数重命名:

    public class Word {
    
        public Word(String correctWord) {
    
      // No Action
    
        }
    
    }
    
  3. 接下来,我们回到测试。我们将新对象作为一个局部变量捕获,以便我们可以对其进行测试:

    @Test
    
    public void oneIncorrectLetter () {
    
        var word = new Word("A");
    
    }
    

下一个设计步骤是考虑一种方法,将猜测传递给 Word 类并返回一个分数。

  1. 将猜测传递进去是一个简单的决定——我们将使用一个我们称之为 guess() 的方法。我们可以将这些决策编码到测试中:

    @Test
    
    public void oneIncorrectLetter () {
    
        var word = new Word("A");
    
        word.guess("Z");
    
    }
    
  2. 使用自动完成功能将 guess() 方法添加到 Word 类中:

图 5.5 – 创建 Word 类

图 5.5 – 创建 Word 类

  1. 点击 Enter 添加方法,然后将参数名称更改为一个描述性的名称:

    public void guess(String attempt) {
    
    }
    
  2. 接下来,让我们添加一种方法来获取该猜测的结果分数。从以下测试开始:

    @Test
    
    public void oneIncorrectLetter () {
    
        var word = new Word("A");
    
        var score = word.guess("Z");
    
    }
    

然后,我们需要稍微思考一下从生产代码中返回什么。

我们可能需要一个某种对象。这个对象必须代表从那个猜测中得到的分数。因为我们的当前用户故事是关于五字母词的分数和每个字母的细节,我们必须返回 完全正确正确字母错误位置字母不存在 之一。

有几种方法可以做到这一点,现在是时候停下来思考它们了。以下是一些可行的方案:

  • 一个具有五个获取器的类,每个获取器返回一个枚举。

  • 一个具有相同获取器的record类型

  • 一个具有iterator方法的类,该方法遍历五个枚举常量。

  • 一个具有iterator方法的类,该方法为每个字母得分返回一个接口。评分代码将为每种得分类型实现一个具体类。这将是一种纯面向对象的方式,为每个可能的输出添加一个回调

  • 一个类,它遍历每个字母的结果,并为每个结果传递一个Java 8 lambda函数。正确的结果会被作为每个字母的回调调用。

这已经有很多设计选项了。TDD 的关键部分是我们现在在编写任何生产代码之前考虑这一点。为了帮助我们做出决定,让我们勾勒出调用代码的样子。我们需要考虑代码的合理扩展——我们是否需要一个单词中多于或少于五个字母?评分规则会改变吗?我们是否应该现在就关心这些事情?未来的读者是否更容易理解这些想法中的任何一个而不是其他?TDD 为我们提供了关于设计决策的快速反馈,这迫使我们现在就进行设计锻炼。

一个主要的决策是我们不会返回每个字母应该有的颜色。这将是一个 UI 代码决策。对于这个核心领域逻辑,我们将只返回字母是正确位置错误不存在的事实。

使用 TDD 轻松勾勒出调用代码,因为它本身就是测试代码本身。在思考了大约 15 分钟要做什么之后,以下是我们在代码中使用的三个设计决策:

  • 支持单词中可变数量的字母

  • 使用简单的枚举INCORRECTPART_CORRECTCORRECT来表示得分

  • 通过单词中的位置访问每个得分,基于零开始

这些决策支持score对象将弥补这一点。让我们继续到设计:

  1. 在测试中捕捉这些决策:

    @Test
    
    public void oneIncorrectLetter() {
    
        var word = new Word("A");
    
        var score = word.guess("Z");
    
        var result = score.letter(0);
    
        assertThat(result).isEqualTo(Letter.INCORRECT);
    
    }
    

我们可以看到这个测试如何锁定我们如何使用对象的设计决策。它根本不提我们将如何实现这些方法。这是 TDD 有效性的关键。我们还在这个测试中捕捉并记录了所有的设计决策。创建这样的可执行规范是 TDD 的一个重要好处。

  1. 现在,运行这个测试。观察它失败。这是一个令人惊讶的重要步骤。

我们可能会认为我们只想看到通过测试。这并不完全正确。TDD 的一部分工作是有信心你的测试是有效的。当我们知道我们还没有编写代码让它通过时,看到测试失败会让我们有信心我们的测试可能检查了正确的事情。

  1. 让我们通过向class Word添加代码来使那个测试通过:

    public class Word {
    
        public Word(String correctWord) {
    
            // Not Implemented
    
        }
    
        public Score guess(String attempt) {
    
            var score = new Score();
    
            return score;
    
        }
    
    }
    
  2. 接下来,创建class Score

    public class Score {
    
        public Letter letter(int position) {
    
            return Letter.INCORRECT;
    
        }
    
    }
    

再次,我们使用了 IDE 快捷键来完成大部分代码编写工作。测试通过了:

图 5.6 – IntelliJ 中的测试通过

图 5.6 – IntelliJ 中的测试通过

我们可以看到测试通过了,并且它运行了 0.139 秒。这当然比任何手动测试都要好。

我们还有一个可重复的测试,我们可以在项目的剩余生命周期中运行它。与手动测试相比,每次运行测试套件都能节省时间。

你会注意到,尽管测试通过了,但代码看起来像是在作弊。测试只期望 Letter.INCORRECT,而代码是硬编码为总是返回那个值。显然,它永远不可能对任何其他值有效!在这个阶段这是预期的。我们的第一个测试已经为我们的代码接口制定了一个初步的设计。它还没有开始驱动完整的实现。我们将通过后续的测试来完成这一点。这个过程被称为 三角测量法,其中我们依赖于添加测试来驱动出缺失的实现细节。通过这样做,我们的所有代码都被测试覆盖了。我们免费获得了 100% 有意义的 代码覆盖率。更重要的是,它将我们的工作分解成更小的块,通过频繁的可交付成果创造进展,并可能导致一些有趣的解决方案。

另一点需要注意是,我们的一个测试引导我们创建了两个类,这些类都由这个测试覆盖。这是非常推荐的。记住,我们的单元测试覆盖的是一个行为,而不是该行为的任何特定实现。

摘要

我们已经迈出了 TDD 的第一步,并了解了每个测试的 AAA 结构。我们看到了如何在编写生产代码之前设计我们的软件和编写测试,并因此得到更干净、更模块化的设计。我们学习了什么是一个好的测试,并了解了一些常用的技术,用于捕捉常见的编程错误和测试抛出异常的代码。

理解在 FIRST 测试中使用 AAA 部分的流程非常重要,因为这为我们提供了一个我们可以可靠遵循的模板。同样重要的是理解设计想法的流程,正如在之前的 Wordz 示例中所使用的那样。编写我们的测试实际上是将我们做出的设计决策捕捉到单元测试代码中。这为我们提供了关于设计是否干净的快速反馈,同时也为未来阅读我们代码的人提供了一个可执行的规范。

在下一章中,我们将添加测试,并为我们的单词评分对象驱动出一个完整的实现。我们将看到 TDD 如何推动工作前进。我们将使用 红、绿、重构 方法来不断改进我们的代码,并保持代码和测试的清洁,而不会过度设计。

问题与答案

  1. 如果我们没有代码可以测试,我们如何知道要编写什么测试?

我们重新审视这种思考方式。测试帮助我们提前设计一小段代码。我们决定我们想要这个代码的接口,然后通过单元测试的 AAA 步骤来捕捉这些决策。我们只编写足够的代码来使测试编译,然后只编写足够的代码来使测试运行并失败。在这个时候,我们有一个可执行的规范来指导我们编写生产代码。

  1. 我们必须坚持每个生产类对应一个测试类吗?

不,这是使用单元测试时常见的误解。每个测试的目标是指定和运行一个行为。这个行为将以某种方式通过代码实现——函数、类、对象、库调用等——但这个测试在没有任何方式约束行为是如何实现的。一些单元测试只测试一个函数。一些类每个公共方法都有一个测试。其他,就像我们在工作示例中看到的那样,会产生多个类来满足测试。

  1. 我们总是使用 AAA 结构吗?

以这种方式开始是一个有用的建议,但我们有时发现我们可以省略或合并一个步骤,从而提高测试的可读性。如果我们没有为静态方法创建任何内容,我们可能会省略 Arrange 步骤。我们可能将 Act 步骤合并到 Assert 步骤中,以简化方法调用,使测试更易读。我们可以将常见的 Arrange 步骤代码提取到一个 JUnit @BeforeEach 注解方法中。

  1. 测试是废弃代码吗?

不。它们被赋予了与生产代码相同的重要性和关注。测试代码的整洁度就像生产代码的整洁度一样。我们测试代码的可读性至关重要。我们必须能够快速浏览测试并迅速了解其存在的原因和作用。测试代码不会在生产环境中部署,但这并不意味着它不那么重要。

第六章:跟随 TDD 的节奏

我们已经看到单个单元测试如何帮助我们探索和捕捉关于代码的设计决策,并保持我们的代码无缺陷且易于使用,但这并不是它们能做的全部。TDD 有节奏可以帮助我们整个开发周期。通过遵循这些节奏,我们在每个步骤中都有一个下一步行动的指南。拥有这种技术结构,我们可以深入思考如何编写良好的代码,并捕捉结果,这非常有帮助。

上一章介绍了第一个节奏。在每个测试中,我们都有编写 Arrange、Act 和 Assert 部分的节奏。接下来,我们将对此进行一些详细的观察。我们将继续介绍一个更大的节奏,它指导我们在细化代码时进行操作,被称为红、绿、重构RGR)循环。它们共同帮助我们编写易于集成到更广泛应用程序中的代码,并使其由简洁、易于理解的代码组成。应用这两个节奏确保我们以速度交付高质量的代码。它为我们提供了在每次编码会话期间要达到的几个小里程碑。这非常有动力,因为我们能感受到朝着构建应用程序目标稳步前进的感觉。

在本章中,我们将介绍以下主题:

  • 跟随 RGR 循环

  • 为 Wordz 编写下一个测试

技术要求

本章的最终代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter06找到。建议您亲自输入代码并思考我们在进行过程中将做出的所有决策,以跟随练习。

跟随 RGR 循环

在上一章中,我们看到了如何将单个单元测试分为三个部分,即 Arrange、Act 和 Assert 部分。这形成了一个简单的工作节奏,引导我们编写每个测试。它迫使我们设计代码的用法——代码的外部。如果我们把对象看作是一个封装边界,那么讨论边界内外的内容是有意义的。公共方法构成了我们对象的表面。Arrange、Act 和 Assert 节奏帮助我们设计这些内容。

我们在这里使用“节奏”这个词,几乎是在音乐意义上的。它是一个恒定、重复的主题,将我们的工作统一起来。在编写测试、编写代码、改进代码以及决定下一个要编写的测试方面,都有一个常规的工作流程。每个测试和代码片段都将不同,但工作节奏是相同的,就像一首不断变化的歌中的稳定节拍一样。

一旦我们编写了测试,我们就转向创建对象内部的代码 – 私有字段和方法。为此,我们利用另一种节奏,称为 RGR。这是一个三步过程,帮助我们建立对测试的信心,创建代码的基本实现,然后安全地对其进行改进。

在本节中,我们将学习在每个三个阶段中需要完成哪些工作。

从红色阶段开始

图 6.1 – 红色阶段

图 6.1 – 红色阶段

我们总是从第一个阶段开始,称为红色阶段。这个阶段的目标是使用 Arrange, Act 和 Assert 模板让我们的测试启动并运行,准备好测试我们接下来要编写的代码。这个阶段最重要的部分是确保测试不通过。我们称之为失败测试,或红色测试,因为大多数图形测试工具使用这种颜色来表示失败测试。

这相当反直觉,不是吗?我们通常在开发过程中力求第一次就能让事情运作正确。然而,我们希望在这个阶段测试失败,以增强我们对它正确工作的信心。如果测试在这个时候通过,那就令人担忧。为什么它会通过?我们知道我们还没有编写我们正在测试的任何代码。如果测试现在通过,这意味着我们可能不需要编写任何新代码,或者我们在测试中犯了错误。进一步阅读部分有一个链接,列出了八个可能导致测试无法正确运行的原因。

这里最常见的错误是断言错误。在继续之前,识别错误并修复它。我们必须有那个红色测试,这样我们才能看到它从失败变为通过,因为我们正确地添加了代码。

保持简单 – 转向绿色

图 6.2 – 绿色阶段

图 6.2 – 绿色阶段

一旦我们有了失败的测试,我们就可以自由地编写使其通过的代码。我们称之为生产代码 – 将成为我们生产系统一部分的代码。我们将生产代码视为一个 黑盒 组件。想想电子中的集成电路,或者可能是一些机械密封单元。组件有内部和外部。内部是我们编写生产代码的地方。这是我们隐藏实现中的数据和算法的地方。我们可以使用我们选择的任何方法 – 面向对象、函数式、声明式或过程式。任何我们喜欢的方法。外部是 应用程序编程接口 (API)。这是我们用来连接我们的组件并使用它来构建更大软件的部分。如果我们选择面向对象的方法,这个 API 将由对象上的公共方法组成。在 TDD 中,我们首先连接的是我们的测试,这让我们能够快速得到关于连接使用难易程度的反馈。

下面的图示显示了不同的部分 – 内部、外部、测试代码以及我们组件的其他用户:

图 6.3 – 黑盒组件的内部和外部

图 6.3 – 黑盒组件的内部和外部

由于我们的实现是封装的,因此我们可以随着了解更多信息而改变主意,而不会破坏测试。

这个阶段有两个指导原则:

  • 使用最简单的代码即可工作:使用最简单的代码非常重要。可能会有一种诱惑,想要使用过度设计的算法,或者可能仅仅为了使用它而使用最新的语言特性。抵制这种诱惑。在这个阶段,我们的目标是让测试通过,没有更多。

  • 不要过度思考实现细节:我们不需要过度思考这一点。我们不需要在第一次尝试时就写出完美的代码。我们可以写一行代码,一个方法,几个方法,或者完全新的类。我们将在下一步改进这段代码。只需记住让测试通过,不要超出这个测试在功能上的覆盖范围。

重构以编写干净的代码

图 6.4 – 重构阶段

图 6.4 – 重构阶段

这是进入软件工程模式的阶段。我们有一些工作简单且通过测试的代码。现在是时候将其精炼成干净的代码——这意味着代码将易于阅读。有了通过测试提供的信心,我们可以自由地应用任何有效的重构技术到我们的代码中。在这个阶段,我们可以使用的重构技术示例包括以下:

  • 提取一个方法以删除重复的代码

  • 重命名一个方法以更好地表达其功能

  • 重命名一个变量以更好地表达其内容

  • 将长方法拆分成几个更小的方法

  • 提取一个更小的类

  • 将长参数列表组合成它自己的类

所有这些技术都有一个目标:使我们的代码更容易理解。这将使它更容易维护。记住在整个这些变化中保持绿色测试通过。到这个阶段结束时,我们将有一个单元测试覆盖了我们设计得在未来更容易工作的生产代码的一部分。这是一个很好的地方。

现在,我们已经熟悉了 RGR 循环的每个阶段应该做什么,让我们将这一点应用到我们的 Wordz 应用程序中。

为 Wordz 编写下一个测试

那么,我们接下来应该编写什么样的测试呢?应该采取一个有用且足够小的步骤,这样我们才不会陷入编写超出测试支持范围的陷阱?在本节中,我们将继续使用 TDD 构建 Wordz 应用程序的评分系统。我们将讨论在每个步骤中我们如何选择前进。

对于下一个测试,一个好的选择是保守行事,只向前迈出很小的一步。我们将添加一个测试来检查单个正确的字母。这将驱使我们编写第一段真正的应用程序逻辑:

  1. 让我们从红色开始。编写一个针对单个正确字母的失败测试:

    @Test
    
    public void oneCorrectLetter() {
    
       var word = new Word("A");
    
       var score = word.guess("A");
    
       assertThat(score.letter(0))
    
          .isEqualTo(Letter.CORRECT);
    
    }
    

这个测试故意与之前的测试相似。不同之处在于它测试的是字母是否正确,而不是不正确。我们故意使用了同一个单词——一个单独的字母,"A"。在编写测试时这一点很重要——使用有助于讲述我们正在测试的内容及其原因的测试数据。这里的情节是,同一个单词的不同猜测将导致不同的分数——这显然是我们正在解决的问题的关键。我们的两个测试案例完全覆盖了任何单字母词猜测的所有可能结果。

使用我们的 IDE 自动完成功能,我们很快对class Word进行了修改。

  1. 现在,让我们通过添加使测试通过的生产代码来转向绿色:

    public class Word {
    
        private final String word;
    
        public Word(String correctWord) {
    
            this.word = correctWord;
    
        }
    
        public Score guess(String attempt) {
    
            var score = new Score(word);
    
            score.assess( 0, attempt );
    
            return score;
    
        }
    
    }
    

这里的目标是让新的测试通过,同时保持现有的测试通过。我们不希望破坏任何现有的代码。我们添加了一个名为word的字段,用于存储我们应猜测的单词。我们添加了一个公共构造函数来初始化这个字段。我们在guess()方法中添加了代码来创建一个新的Score对象。我们决定在这个Score类中添加一个名为assess()的方法。这个方法负责评估我们的猜测应该得到多少分数。我们决定assess()应该有两个参数。第一个参数是我们希望评估分数的单词中字母的零基索引。第二个参数是我们对单词可能是什么的猜测。

我们使用 IDE 帮助我们编写class Score

public class Score {
    private final String correct;
    private Letter result = Letter.INCORRECT ;
    public Score(String correct) {
        this.correct = correct;
    }
    public Letter letter(int position) {
        return result;
    }
    public void assess(int position, String attempt) {
        if ( correct.charAt(position) == attempt.            charAt(position)){
            result = Letter.CORRECT;
        }
    }
}

为了覆盖oneCorrectLetter()测试所测试的新行为,我们添加了前面的代码。与之前assess()方法总是返回Letter.INCORRECT不同,新的测试迫使它转向一个新的方向。现在assess()方法必须能够在猜测的字母正确时返回正确的分数。

为了实现这一点,我们添加了一个名为result的字段来保存最新的分数,从letter()方法返回结果的代码,以及assess()方法中的代码来检查我们的猜测的第一个字母是否与我们的单词的第一个字母匹配。如果我们做对了,我们的两个测试现在都应该通过。

运行所有测试以查看我们的进展:

图 6.5 – 两个测试通过

图 6.5 – 两个测试通过

这里有很多东西需要回顾。注意我们的两个测试都是通过的。通过运行到目前为止的所有测试,我们已经证明我们没有破坏任何东西。我们对代码所做的更改添加了新功能,而没有破坏任何现有功能。这是强大的。注意另一个明显的方面——我们知道我们的代码是有效的。我们不必等到手动测试阶段,也不必等到某个集成点,或者等到用户界面准备好。我们知道我们的代码现在有效。作为一个小细节,注意 0.103 秒的时间长度。两个测试在一十分之一秒内完成,比手动测试快得多。一点也不差。

在设计方面,我们已经前进了一步。我们已经超越了使用代码检测正确和错误猜测的硬编码Letter.INCORRECT结果。我们在Score类中添加了重要的设计概念assess()方法。这是非常重要的。我们的代码现在揭示了一个设计;Score对象将知道正确的word,并且能够使用assess()方法对猜测attempt进行评估。这里使用的术语形成了对我们要解决的问题的良好描述。我们想要评估一个猜测并返回一个单词得分。

现在测试通过了,我们可以继续前进——但 TDD 的一个重要部分是持续改进我们的代码,并朝着更好的设计努力,由测试来指导。我们现在进入了 RGR 周期的重构阶段。再次,TDD 将控制权交还给我们。我们想要重构吗?我们应该重构什么?为什么?现在做这个值得吗,还是我们可以推迟到以后的步骤?

让我们回顾一下代码,寻找代码异味。代码异味是表明实现可能需要改进的一个迹象。这个名称来源于食物开始变质时产生的气味。

代码异味之一是代码重复。单独来看,一点代码重复可能没问题。但它是一个早期警告,表明可能使用了过多的复制粘贴,我们没有更直接地捕捉到一个重要的概念。让我们回顾我们的代码以消除重复。我们还可以寻找其他两种常见的代码异味——不清晰的命名,以及如果将它们提取到自己的方法中会更容易阅读的代码块。显然,这是主观的,我们都会对要做什么有不同的看法。

定义代码异味

术语代码异味最初出现在 C2 维基上。阅读一下给出的代码异味示例是值得的。它有一个有用的定义,指出代码异味是需要审查的东西,但可能不一定需要改变:

wiki.c2.com/?CodeSmell.

让我们反思一下assess()方法内部。它似乎充满了太多的代码。让我们提取一个辅助方法来增加一些清晰度。如果我们觉得这个改动没有帮助,我们总是可以撤销它。

  1. 让我们进行重构。为了清晰起见,提取一个isCorrectLetter()方法:

    public void assess(int position, String attempt) {
    
        if (isCorrectLetter(position, attempt)){
    
            result = Letter.CORRECT;
    
        }
    
    }
    
    private boolean isCorrectLetter(int position,
    
                                    String attempt) {
    
        return correct.charAt(position) ==
    
               attempt.charAt(position);
    
    }
    

再次,我们运行所有测试以证明这次重构没有破坏任何东西。测试通过了。在前面的代码中,我们将一个复杂的条件语句拆分成了它自己的私有方法。这样做的原因是为了在代码中引入一个方法名。这是一种有效的代码注释方式——以编译器帮助我们保持代码更新。它有助于assess()方法中的调用代码讲述一个更好的故事。现在的if语句几乎可以说成是“如果这是一个正确的字母”。这是一个强大的可读性辅助工具。

可读性发生在写作过程中,而不是阅读过程中

编程初学者常见的一个问题是“我如何提高阅读代码的能力?”

这是一个有效的问题,因为任何一行代码都将被人类程序员阅读得比它被编写时更多。可读性是在编写代码时赢得或失去的。任何一行代码都可以被编写成易于阅读或难以阅读。作为作者,我们有权选择。如果我们始终选择易于阅读而不是其他任何东西,其他人会发现我们的代码易于阅读。

写得不好的代码难以阅读。遗憾的是,编写这样的代码很容易。

在这个阶段,我还有两个区域想要重构。第一个是一个简单的提高测试可读性的方法。

让我们重构测试代码以提高其清晰度。我们将添加一个自定义assert方法:

@Test
public void oneCorrectLetter() {
    var word = new Word("A");
    var score = word.guess("A");
    assertScoreForLetter(score, 0, Letter.CORRECT);
}
private void assertScoreForLetter(Score score,
                  int position, Letter expected) {
    assertThat(score.letter(position))
          .isEqualTo(expected);
}

之前的代码已经将assertThat()断言移动到了它自己的私有方法中。我们称这个方法为assertScoreForLetter(),并给它一个描述所需信息的签名。这个改变提供了对测试正在做什么的更直接描述,同时减少了重复的代码。它还保护我们免受断言实现变化的影响。这似乎是朝着更全面的断言迈出的一步,一旦我们支持更多字母的猜测,我们就会需要这种断言。再一次,我们不是在源代码中添加注释,而是使用方法名来捕捉assertThat()代码的意图。编写AssertJ 自定义匹配器是另一种实现方式。

我们可能想要做的下一个重构可能有点更具争议性,因为它是一个设计变更。让我们进行重构,讨论它,然后如果我们不喜欢它,可能重新调整代码。这样就可以节省数小时的时间,去思考这个变化会是什么样子。

  1. 让我们改变一下在assess()方法中指定要检查的字母位置的方式:

    public class Score {
    
        private final String correct;
    
        private Letter result = Letter.INCORRECT ;
    
        private int position;
    
        public Score(String correct) {
    
            this.correct = correct;
    
        }
    
        public Letter letter(int position) {
    
            return result;
    
        }
    
        public void assess(String attempt) {
    
            if (isCorrectLetter(attempt)){
    
                result = Letter.CORRECT;
    
            }
    
        }
    
        private boolean isCorrectLetter(String attempt) {
    
            return correct.charAt(position) == attempt.        charAt(position);
    
        }
    
    }
    

我们已经从assess()方法中移除了position参数,并将其转换为名为position的字段。我们的意图是简化assess()方法的用法。它不再需要明确指出正在评估哪个位置。这使得代码更容易调用。我们刚刚添加的代码只会在位置为零的情况下工作。这是可以的,因为在这个阶段,我们的测试只需要这一点。我们将在以后使这段代码适用于非零值。

这个改变之所以有争议,是因为它要求我们更改测试代码以反映方法签名中的变化。我已经准备好接受这一点,因为我可以使用我的 IDE 自动重构支持来安全地完成这项工作。这也引入了一个风险:在我们调用isCorrectLetter()之前,我们必须确保位置被设置为正确的值。我们将看看这会如何发展。这可能使代码更难以理解,在这种情况下,简化的assess()方法可能不值得。如果我们发现这种情况,我们可以改变我们的方法。

现在我们已经到达了一个点,代码可以处理任何单个字母的单词。接下来我们应该尝试什么?看起来我们应该继续到两个字母的单词,看看这会如何改变我们的测试和逻辑。

使用两个字母组合推进设计

我们可以继续添加测试,目的是让代码能够处理两个字母的组合。在代码能够处理单个字母之后,这是一个明显的下一步。为了做到这一点,我们需要在代码中引入一个新概念:一个字母可以存在于单词中,但不在我们猜测的位置:

  1. 让我们从编写一个测试开始,测试第二个字母是否在错误的位置:

    @Test
    
    void secondLetterWrongPosition() {
    
        var word = new Word("AR");
    
        var score = word.guess("ZA");
    
        assertScoreForLetter(score, 1,
    
                             Letter.PART_CORRECT);
    
    }
    

让我们更改assess()方法内部的代码,使其通过并保持现有测试通过。

  1. 让我们添加初始代码来检查我们猜测中的所有字母:

    public void assess(String attempt) {
    
        for (char current: attempt.toCharArray()) {
    
            if (isCorrectLetter(current)) {
    
                result = Letter.CORRECT;
    
            }
    
        }
    
    }
    
    private boolean isCorrectLetter(char currentLetter) {
    
        return correct.charAt(position) == currentLetter;
    
    }
    

这里主要的改变是评估attempt中的所有字母,而不是假设它只有一个字母。当然,这就是这次测试的目的——消除这种行为。通过选择将attempt字符串转换为char数组,代码看起来读起来相当顺畅。这个简单的算法遍历每个char,使用current变量来表示要评估的当前字母。这要求对isCorrectLetter()方法进行重构,以便它能够接受并处理char输入——好吧,或者将char转换为String,这看起来很丑陋。

单个字母行为的原始测试仍然通过,这是必须的。我们知道我们循环内部的逻辑不可能正确——我们只是在覆盖result字段,它最多只能存储一个字母的结果。我们需要改进这个逻辑,但我们不会在添加测试之前这么做。这种工作方式被称为三角测量法——我们通过添加更具体的测试来使代码更通用。对于我们的下一步,我们将添加代码来检测我们的尝试字母是否在单词中的其他位置出现。

  1. 让我们添加代码来检测当正确字母在错误位置时:

    public void assess(String attempt) {
    
        for (char current: attempt.toCharArray()) {
    
            if (isCorrectLetter(current)) {
    
                result = Letter.CORRECT;
    
            } else if (occursInWord(current)) {
    
                result = Letter.PART_CORRECT;
    
            }
    
        }
    
    }
    
        private boolean occursInWord(char current) {
    
            return
    
              correct.contains(String.valueOf(current));
    
        }
    

我们添加了对一个新私有方法occursInWord()的调用,如果当前字母在任何位置出现在单词中,它将返回true。我们已经确定当前字母不在正确的位置。这应该为我们提供一个关于正确字母不在正确位置的清晰结果。

这段代码使所有三个测试都通过。这立即引起了怀疑,因为不应该发生这种情况。我们已知我们的逻辑会覆盖单个result字段,这意味着许多组合将失败。发生的事情是我们的最新测试相当薄弱。我们可以回过头来加强那个测试,添加一个额外的断言。或者,我们可以保持它不变,并编写另一个测试。这种困境在开发中很常见,通常不值得花太多时间去思考它们。无论哪种方式都会推动我们前进。

让我们添加另一个测试,以完全锻炼第二个字母位置错误的行为。

  1. 添加一个新的测试,以锻炼所有三种评分可能性:

    @Test
    
    void allScoreCombinations() {
    
        var word = new Word("ARI");
    
        var score = word.guess("ZAI");
    
        assertScoreForLetter(score, 0, Letter.INCORRECT);
    
        assertScoreForLetter(score, 1,
    
                             Letter.PART_CORRECT);
    
        assertScoreForLetter(score, 2, Letter.CORRECT);
    
    }
    

如预期的那样,这个测试失败了。原因在检查生产代码时很明显。是因为我们一直在同一个单值字段中存储结果。现在我们有了针对这个问题的失败测试,我们可以纠正评分逻辑。

  1. 为每个字母位置单独存储结果添加一个List

    public class Score {
    
        private final String correct;
    
        private final List<Letter> results =
    
                                 new ArrayList<>();
    
        private int position;
    
        public Score(String correct) {
    
            this.correct = correct;
    
        }
    
        public Letter letter(int position) {
    
            return results.get(position);
    
        }
    
        public void assess(String attempt) {
    
            for (char current: attempt.toCharArray()) {
    
                if (isCorrectLetter(current)) {
    
                    results.add(Letter.CORRECT);
    
                } else if (occursInWord(current)) {
    
    results.add(Letter.PART_CORRECT);
    
                } else {
    
                    results.add(Letter.INCORRECT);
    
                }
    
                position++;
    
            }
    
        }
    
        private boolean occursInWord(char current) {
    
            return
    
             correct.contains(String.valueOf(current));
    
        }
    
        private boolean isCorrectLetter(char
    
          currentLetter) {
    
            return correct.charAt(position) ==
    
                     currentLetter;
    
        }
    
    }
    

这需要尝试几次才能正确完成,因为我们刚刚添加的测试中出现了失败。前面的最终结果通过了所有四个测试,证明它能够正确地评分三字母词的所有组合。主要的变化是将单值的result字段替换为resultsArrayList,并将letter(position)实现方法更改为使用这个新的结果集合。运行这个更改导致了一个失败,因为代码不能再检测到错误的字母。之前,这已经被result字段的默认值处理了。现在,我们必须为每个字母显式地做这件事。然后我们需要在循环中更新位置,以跟踪我们正在评估的字母位置。

我们添加了一个测试,看到它变红并失败,然后添加了代码使测试变绿并通过,所以现在是时候重构了。测试和生产代码中都有一些看起来不太对劲的地方。

在生产代码class Score中,assess()方法的循环体看起来很笨拙。它有一个逻辑和一系列if-else-if块的长的循环体。感觉代码可以更清晰。我们可以将循环体提取到一个方法中。方法名然后给我们一个地方来描述每个事物正在发生什么。循环然后变得更短,更容易理解。我们还可以用更简单的结构替换if-else-if层。

  1. 让我们将循环体内的逻辑提取到一个scoreFor()方法中:

    public void assess(String attempt) {
    
        for (char current: attempt.toCharArray()) {
    
            results.add( scoreFor(current) );
    
            position++;
    
        }
    
    }
    
    private Letter scoreFor(char current) {
    
        if (isCorrectLetter(current)) {
    
            return Letter.CORRECT;
    
        }
    
        if (occursInWord(current)) {
    
            return Letter.PART_CORRECT;
    
        }
    
        return Letter.INCORRECT;
    
    }
    

这读起来更清晰。scoreFor()方法的主体现在是对评分每个字母的规则的简洁描述。我们用更简单的if-return结构替换了if-else-if结构。我们计算出得分,然后立即退出方法。

下一个任务是清理测试代码。在 TDD 中,测试代码与生产代码具有相同的优先级。它是系统文档的一部分。它需要与生产代码一起维护和扩展。我们对待测试代码的可读性与对待生产代码一样重要。

测试代码中的代码异味主要集中在断言上。有两件事可以改进。代码中有一个明显的重复,我们可以消除。还有一个关于一个测试中应该有多少断言的问题。

  1. 让我们通过提取一个方法来删除重复的断言代码:

    @Test
    
    void allScoreCombinations() {
    
        var word = new Word("ARI");
    
        var score = word.guess("ZAI");
    
        assertScoreForGuess(score, INCORRECT,
    
                                   PART_CORRECT,
    
                                   CORRECT);
    
    }
    
    private void assertScoreForGuess(Score score, Letter…
    
        for (int position=0;
    
                 position < expectedScores.length;
    
                 position++){
    
            Letter expected = expectedScores[position];
    
            assertThat(score.letter(position))
    
                .isEqualTo(expected);
    
        }
    
    }
    

通过提取assertScoreForGuess()方法,我们创建了一种检查可变数量字母分数的方法。这消除了我们之前复制的assert行,并提高了抽象级别。由于我们现在用INCORRECT, PART_CORRECT, CORRECT的顺序来描述测试,测试代码的阅读性也变得更加清晰。通过向这些enum添加静态导入,语法杂乱也得到了有益的减少。

现在可以手动修改早期的测试,以利用这个新的断言辅助工具。这允许我们将原始的assertScoreForLetter()方法内联,因为它不再增加价值。

  1. 现在,让我们看看重构后的最终测试集:

    package com.wordz.domain;
    
    import org.junit.jupiter.api.Test;
    
    import static com.wordz.domain.Letter.*;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    public class WordTest {
    
        @Test
    
        public void oneIncorrectLetter() {
    
            var word = new Word("A");
    
            var score = word.guess("Z");
    
            assertScoreForGuess(score, INCORRECT);
    
        }
    
        @Test
    
        public void oneCorrectLetter() {
    
            var word = new Word("A");
    
            var score = word.guess("A");
    
            assertScoreForGuess(score, CORRECT);
    
        }
    
        @Test
    
        public void secondLetterWrongPosition() {
    
            var word = new Word("AR");
    
            var score = word.guess("ZA");
    
            assertScoreForGuess(score,  INCORRECT,
    
                                        PART_CORRECT);
    
        }
    
        @Test
    
        public void allScoreCombinations() {
    
            var word = new Word("ARI");
    
            var score = word.guess("ZAI");
    
            assertScoreForGuess(score,  INCORRECT,
    
                                        PART_CORRECT,
    
                                        CORRECT);
    
        }
    
        private void assertScoreForGuess(Score score,
    
            Letter... expectedScores) {
    
            for (int position = 0;
    
                  position < expectedScores.length;
    
                  position++) {
    
                Letter expected =
    
                        expectedScores[position];
    
                assertThat(score.letter(position))
    
                        .isEqualTo(expected);
    
            }
    
        }
    
    }
    

这看起来是一套全面的测试用例。每一行生产代码都是通过添加新的测试来探索行为的新方面而产生的直接结果。测试代码看起来易于阅读,生产代码也看起来实现清晰,调用简单。测试形成了一个可执行的评分规则规范,用于猜测单词。

这已经实现了我们在本次编码会话开始时设定的所有目标。我们使用 TDD 扩展了Score类的功能。我们遵循 RGR 周期,确保我们的测试代码和生产代码遵循良好的工程实践。我们有经过单元测试验证的健壮代码,以及一个使代码易于从我们的更广泛应用程序中调用的设计。

摘要

在本章中,我们已经将 RGR 周期应用于我们的代码。我们看到了如何将工作分解成单独的任务,这导致我们对测试的信心增强,快速通往简单的生产代码,以及减少改进代码可维护性所需的时间。我们检查了从生产代码和测试代码中移除代码异味。在本章的工作中,我们使用了帮助我们前进并决定下一步应该编写哪些测试的想法。本章的技术使我们能够编写多个测试,并逐步驱除生产代码中的详细逻辑。

在下一章中,我们将学习一些被称为 SOLID 原则的面向对象设计思想,使我们能够使用 TDD 进一步扩展我们的应用程序。

问题与答案

  1. TDD 的两个关键节奏是什么?

安排、行动、断言和 RGR。第一个节奏帮助我们设计生产代码的接口时编写测试的主体。第二个节奏帮助我们创建并完善该生产代码的实现。

  1. 我们如何在编写代码之前编写测试?

我们不是在考虑如何实现某些代码,而是在考虑如何调用这些代码。我们通过单元测试捕捉这些设计决策。

  1. 测试应该是废弃的代码吗?

No. 在 TDD 中,单元测试与生产代码具有同等的重要性。它们以同样的细心编写,并存储在相同的代码仓库中。唯一的区别是测试代码本身将不会出现在交付的可执行文件中。

  1. 每次测试通过后,我们都需要重构吗?

No. 利用这段时间作为决定需要哪些重构的机会。这适用于生产代码和测试代码。有时,可能不需要任何重构,我们就继续前进。其他时候,我们可能会感觉到更大的改变会更有益。我们可能会选择将这个更大的改变推迟到我们有了更多代码之后再进行。

进一步阅读

  • 在红色上获得绿色

杰夫·兰格(Jeff Langr)的文章,描述了测试可以通过错误的原因通过八种不同的方式。如果我们意识到这些问题,我们就可以在工作中避免它们。

medium.com/pragmatic-programmers/3-5-getting-green-on-red-d189240b1c87

  • 重构:现有代码的设计改进,马丁·福勒(ISBN 978-0134757599)

重构代码的权威指南。本书描述了代码的逐步转换,这些转换保留了其行为但提高了清晰度。有趣的是,大多数转换都是成对出现的,例如被称为提取方法内联方法的技术对。这反映了所涉及的权衡。

  • AssertJ 自定义匹配器的文档

本章简要介绍了AssertJ 自定义匹配器。这些是非常有用的创建可重用自定义断言的方法。这些断言类本身是可单元测试的,并且可以使用测试驱动开发(TDD)的测试先行方法编写。仅凭这一点,它们就优于添加一个私有方法来处理自定义断言。

以下链接提供了 AssertJ 在 GitHub 上的分发提供的许多示例。

github.com/assertj/assertj-examples/tree/main/assertions-examples/src/test/java/org/assertj/examples/custom

第七章:驱动设计 – TDD 和 SOLID

到目前为止,我们已经创建了一些基本的单元测试,这些测试驱动了几个类的简单设计。我们体验了测试驱动开发TDD)如何使设计选择的决定变得核心。为了构建更大的应用程序,我们需要能够处理更复杂的设计。为此,我们将应用一些推荐的评估方法,以确定哪种设计比另一种设计更可取。

SOLID 原则是五个设计指南,它们引导设计变得更加灵活和模块化。单词SOLID是一个缩写词,其中每个字母代表一个以该字母开头的五个原则之一。这些原则在它们被这个名称所知之前就已经存在。在我的经验中,它们已被证明是有帮助的,了解每个原则带来的好处以及我们如何将它们应用到我们的代码中是值得的。为此,我们将在本章中使用一个运行代码示例。这是一个简单的程序,它使用简单的美国信息交换标准代码ASCII)艺术在控制台上绘制各种形状。

在我们开始之前,让我们考虑学习这五个原则的最佳顺序。缩写词SOLID容易说,但并不是学习原则的最简单方式。一些原则建立在其他原则之上。经验表明,有些原则比其他原则使用得更多,尤其是在进行 TDD 时。因此,我们将按照SDLOI的顺序回顾这些原则。当然,它听起来并不那么好,正如您所同意的,但它构成了更好的学习顺序。

最初,SOLID 原则被构想为适用于面向对象编程(OOP)中类的模式,但它们的用途更广泛。它们同样适用于类中的单个方法以及类本身。它们也适用于微服务互连的设计以及函数式编程中的函数设计。在本章中,我们将看到在类级别和方法级别应用示例。

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

  • 测试指南–我们驱动设计

  • 单一职责原则SRP)–简单的构建块

  • 依赖倒置原则DIP)–隐藏无关细节

  • 里氏替换原则LSP)–可替换的对象

  • 开放封闭原则OCP)–可扩展的设计

  • 接口隔离原则ISP)–有效的接口

技术要求

本章的代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter07找到。提供了一个运行示例代码,该代码使用所有五个 SOLID 原则绘制形状。

测试指南 – 我们驱动设计

第五章,“编写我们的第一个测试”,我们编写了第一个测试。为了做到这一点,我们经历了一系列的设计决策。让我们回顾一下那个初始的测试代码,并列出我们不得不做出的所有设计决策,如下:

@Test
public void oneIncorrectLetter() {
    var word = new Word("A");
    var score = word.guess("Z");
    assertThat( score.letter(0) ).isEqualTo(Letter.INCORRECT);
}

我们决定以下事项:

  • 要测试什么

  • 如何命名测试

  • 如何命名待测试的方法

  • 应将该方法放在哪个类上

  • 该方法的签名

  • 类的构造函数签名

  • 哪些其他对象应该协作

  • 那次协作中涉及的方法签名

  • 该方法输出的形式

  • 如何访问该输出并断言其已成功

这些都是我们人类大脑必须做出的设计决策。TDD 在设计和决定代码实现方式时让我们非常亲自动手。说实话,我很高兴。设计是有回报的,TDD 提供了有助的脚手架而不是规定性的方法。TDD 充当指南,提醒我们尽早做出这些设计决策。它还提供了一种将这些决策作为测试代码进行记录的方法。不多也不少。

在我们做出这些决策时,使用诸如结对编程或团队编程(也称为集体编程)等技术可能会有所帮助——然后,我们将更多的经验和想法添加到我们的解决方案中。单独工作,我们只能根据自己的经验做出最好的决定。

关键点在于,TDD 不会也不会为我们做出这些决策。我们必须自己做出。因此,有一些指导方针来引导我们做出更好的设计是有用的。一套被称为SOLID 原则的五个设计原则是有帮助的。SOLID 是以下五个原则的缩写:

  • SRP

  • OCP

  • LSP

  • ISP

  • DIP

在接下来的章节中,我们将学习这些原则是什么以及它们如何帮助我们编写良好设计的代码和测试。我们将从 SRP 开始,这是任何程序设计风格的基础性原则。

SRP – 简单的构建块

在本节中,我们将检查第一个原则,即 SRP。在整个章节中,我们将使用一个单一的代码示例。这将阐明每个原则是如何应用于面向对象OO)设计的。我们将查看一个经典的 OO 设计示例:绘制形状。以下图是统一建模语言UML)的设计概览,描述了章节中展示的代码:

图 7.1 – 形状代码的 UML 图

图 7.1 – 形状代码的 UML 图

此图显示了 GitHub 文件夹中本章可用的 Java 代码概览。我们将使用代码的特定部分来展示每个 SOLID 原则是如何被用来创建这个设计的。

UML 图

UML(统一建模语言)是由 Grady Booch、Ivar Jacobson 和 James Rumbaugh 在 1995 年创建的。UML 是一种在高级别可视化面向对象设计的方法。前面的图是 UML 类图。UML 还提供了许多其他有用的图表。您可以在www.packtpub.com/product/uml-2-0-in-action-a-project-based-tutorial/9781904811558了解更多信息。

SRP 引导我们将代码分解成封装我们解决方案单一方面的片段。这可能是一种技术方面的本质——例如读取数据库表——或者可能是一种业务规则。无论如何,我们将不同的方面拆分到不同的代码片段中。每个代码片段只负责一个细节,这就是SRP这个名字的由来。另一种看待方式是,一个代码片段应该只有一个改变的理由。让我们在以下章节中探讨为什么这是一个优势。

过多的职责使代码更难处理

常见的编程错误是将过多的职责组合到一个代码块中。如果我们有一个可以生成超文本标记语言HTML)、执行业务规则和从数据库表中获取数据的类,那么这个类将有三个改变的理由。任何对这些区域之一的更改都需要时,我们都会冒着做出破坏其他两个方面的代码更改的风险。这个术语的技术名称是代码高度耦合。这导致一个区域的更改会波及到其他区域。

我们可以在以下图中将这一点可视化为代码块A

图 7.2 – 单个组件:多个改变的理由

图 7.2 – 单个组件:多个改变的理由

A处理三件事,因此对其中任何一项的更改都意味着对A的更改。为了改进这一点,我们应用 SRP(单一职责原则)并将负责创建 HTML、应用业务规则和访问数据库的代码分离出来。这三个代码块——ABC——现在只有一个改变的理由。更改任何单个代码块都不应该导致对其他块的连锁反应。

我们可以在以下图中可视化这一点:

图 7.3 – 多个组件:一个改变的理由

图 7.3 – 多个组件:一个改变的理由

每个代码块只处理一件事,并且只有一个改变的理由。我们可以看到 SRP(单一职责原则)有助于限制未来代码更改的范围。它还使得在大型代码库中查找代码变得更加容易,因为它是逻辑上组织的。

应用 SRP(单一职责原则)带来其他好处,如下所示:

  • 代码重用能力

  • 简化未来的维护

代码重用能力

代码的重用一直是软件工程的目标。从头开始创建软件需要时间,花费金钱,并阻止软件工程师做其他事情。如果我们创建了一些通用的东西,我们尽可能再次使用它是合理的。当我们创建了大型、特定应用的软件时,这种障碍就出现了。它们高度专业化的事实意味着它们只能在原始环境中使用。

通过创建更小、更通用的软件组件,我们将在不同的环境中再次使用它们。组件旨在完成的范围越小,我们越有可能在不修改的情况下重用它。如果我们有一个只做一件事的小函数或类,那么在代码库中重用它就变得容易了。它甚至可能成为框架或库的一部分,我们可以在多个项目中重用它。

SRP 并不能保证代码可重用,但它旨在减少任何代码片段的作用范围。将代码视为一系列构建块的方式,其中每个构建块都完成整体任务的一部分,更有可能产生可重用的组件。

简化的未来维护

当我们编写代码时,我们意识到我们不仅仅是在解决当前的问题,而是在编写可能在未来被重新访问的代码。这可能是团队中的其他人或我们自己。我们希望使未来的工作尽可能简单。为了实现这一点,我们需要保持代码的良好工程化——使其安全且易于以后使用。

重复的代码是维护问题——它使未来的代码更改复杂化。如果我们复制粘贴一段代码三次,比如说,当时我们很清楚我们在做什么。我们有一个需要发生三次的概念,所以我们粘贴了三次。但当再次阅读代码时,那种思考过程已经丢失了。它只是看起来像三段不相关的代码。我们通过复制粘贴丢失了工程信息。我们需要逆向工程那段代码,以找出需要更改的三个地方。

反例 – 违反 SRP 的形状代码

为了看到应用 SRP 的价值,让我们考虑一个没有使用它的代码片段。以下代码片段有一个形状列表,当调用 draw() 方法时,所有形状都会被绘制:

public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();
    public void add(Shape s) {
        allShapes.add(s);
    }
    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
g.drawText(t.getText());
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    for (int row = 0;
                          row < r.getHeight();
                          row++) {
                        g.drawLine(0, r.getWidth());
                    }
            }
        }
    }
}

我们可以看到,这段代码有四个职责,如下所示:

  • 使用 add() 方法管理形状列表

  • 使用 draw() 方法绘制列表中的所有形状

  • switch 语句中知道每种形状的类型

  • case 语句中有绘制每种形状类型的实现细节

如果我们想添加一种新的形状类型——例如三角形——那么我们需要更改此代码。这将使它变得更长,因为我们需要在新的case语句中添加有关如何绘制形状的详细信息。这使得代码更难阅读。该类还必须要有新的测试。

我们能否改变此代码以使添加新类型的形状更容易?当然可以。让我们应用 SRP 并进行重构。

应用 SRP 以简化未来的维护

我们将逐步重构此代码以应用 SRP。首先要做的是将如何绘制每种形状的知识从该类中移出,如下所示:

package shapes;
import java.util.ArrayList;
import java.util.List;
public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();
    public void add(Shape s) {
        allShapes.add(s);
    }
    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
                    t.draw(g);
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    r.draw(g);
            }
        }
    }
}

之前在case语句块中的代码已被移动到形状类中。以下是一个例子,我们可以看到以下代码片段中的变化:

public class Rectangle {
    private final int width;
    private final int height;
    public Rectangle(int width, int height){
        this.width = width;
        this.height = height;
    }
    public void draw(Graphics g) {
        for (int row=0; row < height; row++) {
            g.drawHorizontalLine(width);
        }
    }
}

我们可以看到Rectangle类现在只负责知道如何绘制矩形。它不做其他任何事情。它唯一需要改变的原因是如果我们需要改变矩形的绘制方式。这种情况不太可能发生,这意味着我们现在有一个稳定的抽象。换句话说,Rectangle类是我们可以依赖的构建块。它不太可能改变。

如果我们检查我们的重构Shapes类,我们会看到它也得到了改进。由于我们将它移动到了TextBoxRectangle类中,它少了一个责任。它已经更容易阅读,也更容易测试。

SRP

做一件事,做好这件事。代码块只有一个改变的理由。

可以进行更多改进。我们注意到Shapes类保留了其switch语句,并且每个case语句看起来都是重复的。它们都做同样的事情,即在一个形状类上调用draw()方法。我们可以通过完全替换switch语句来改进这一点——但这将留待下一节介绍 DIP 时再进行。

在我们这样做之前,让我们考虑一下 SRP 如何应用于我们的测试代码本身。

组织测试以具有单一责任

SRP 还帮助我们组织我们的测试。每个测试应该只测试一件事情。这可能是一条单一的快乐路径或一个单一的边界条件。这使得定位任何故障变得简单。我们找到失败的测试,因为它只涉及我们代码的一个方面,所以很容易找到必须修复缺陷的代码。每个测试只有一个断言的建议自然地源于此。

分离具有不同配置的测试

有时,一组对象可以以多种不同的方式协作。如果为该组编写每个配置的单个测试,则这些测试通常更好。我们最终得到多个更小的测试,更容易处理。

这是对将 SRP 应用于那一组对象每个配置并通过为每个特定配置编写一个测试来捕获该配置的一个示例。

我们已经看到 SRP 如何帮助我们创建简单的代码构建块,这些构建块更容易测试和操作。接下来要查看的强大的 SOLID 原则是 DIP。这是一个管理复杂性的非常强大的工具。

DIP – 隐藏无关细节

在本节中,我们将学习 DIP 如何使我们能够将代码分割成可以独立变化的单独组件。然后我们将看到这如何自然地引导到 SOLID 原则中的 OCP 部分。

应用 SRP 后的Shapes类:

package shapes;
import java.util.ArrayList;
import java.util.List;
public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();
    public void add(Shape s) {
        allShapes.add(s);
    }
    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
                    t.draw(g);
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    r.draw(g);
            }
        }
    }
}

这段代码在维护Shape对象列表并绘制它们方面工作得很好。问题是它对它应该绘制的形状类型了解得太多。draw()方法向系统提供了一个Shape,然后我们必须修改这个switch语句以及相关的 TDD 测试代码。

一个类知道另一个类的技术术语是,一个Shapes依赖于TextBoxRectangle类。我们可以在以下 UML 类图中直观地表示这一点:

图 7.4 – 依赖于细节

图 7.4 – 依赖于细节

我们可以看到Shapes类直接依赖于RectangleTextBox类的细节。这可以通过 UML 类图中箭头的方向来体现。这些依赖关系使得使用Shapes类变得更加困难,以下是一些原因:

  • 我们必须修改Shapes类以添加一种新的形状

  • 任何对具体类如Rectangle的更改都将导致这段代码发生变化

  • Shapes类将会变得更长,也更难以阅读

  • 我们最终会有更多的测试用例

  • 每个测试用例都将与具体类如Rectangle耦合

这是一个非常过程化的创建处理多种形状的类的办法。它违反了 SRP 原则,因为它做了太多的事情,并且对每种形状对象了解得过于详细。Shapes类依赖于具体类如RectangleTextBox的细节,这直接导致了上述问题。

幸运的是,有一种更好的方法。我们可以利用接口的力量来改进它,使得Shapes类不依赖于这些细节。这被称为 DI。让我们看看它是什么样子。

将 DI 应用于形状代码

我们可以通过将draw()方法应用于我们的Shape接口来改进形状代码,如下所示:

package shapes;
public interface Shape {
    void draw(Graphics g);
}

这个接口是我们对每个形状所具有的单个责任的抽象。每个形状必须知道当调用draw()方法时如何绘制自己。下一步是让我们的具体形状类实现这个接口。

Rectangle类为例。你可以在这里看到它:

public class Rectangle implements Shape {
    private final int width;
    private final int height;
    public Rectangle(int width, int height){
        this.width = width;
        this.height = height;
    }
    @Override
    public void draw(Graphics g) {
        for (int row=0; row < height; row++) {
            g.drawHorizontalLine(width);
        }
    }
}

我们现在将面向对象的多态概念引入了我们的形状类。这打破了Shapes类对RectangleTextBox类的依赖。Shapes类现在只依赖于Shape接口。它不再需要知道每种形状的类型。

我们可以将Shapes类重构如下:

public class Shapes {
    private final List<Shape> all = new ArrayList<>();
    public void add(Shape s) {
        all.add(s);
    }
    public void draw(Graphics graphics) {
        all.forEach(shape->shape.draw(graphics));
    }
}

这次重构完全移除了switch语句和getType()方法,使代码更容易理解和测试。如果我们添加一种新的形状,Shapes就不再需要更改。我们已经打破了知道形状类细节的依赖。

通过以下代码片段,我们可以看到一次小的重构将传递给draw()方法的Graphics参数移动到一个字段中,该字段在构造函数中初始化:

public class Shapes {
    private final List<Shape> all = new ArrayList<>();
    private final Graphics graphics;
    public Shapes(Graphics graphics) {
this.graphics = graphics;
    }
    public void add(Shape s) {
        all.add(s);
    }
    public void draw() {
        all.forEach(shape->shape.draw(graphics));
    }
}

这就是 DIP 在起作用。我们在Shape接口中创建了一个抽象。Shapes类是这个抽象的消费者。实现该接口的类是提供者。这两组类只依赖于抽象;它们不依赖于彼此内部的细节。在Shapes类中没有对Rectangle类的引用,在Rectangle类中也没有对Shapes的引用。我们可以在以下 UML 类图中看到这种依赖关系的逆转——与图 7.4相比,看看依赖箭头的方向是如何改变的:

图 7.5 – 逆转依赖关系

图 7.5 – 逆转依赖关系

在这个 UML 图的版本中,描述类之间依赖关系的箭头指向相反的方向。依赖关系已经被逆转——因此,这个原则的名称。现在我们的Shapes类依赖于我们的抽象,即Shape接口。同样,所有Rectangle类和TextBox类的具体实现也是如此。我们已经逆转了依赖图,并将箭头颠倒过来。DI 完全解耦了类,因此非常强大。当我们查看第八章测试替身 – 模拟和存根时,我们将看到这如何导致 TDD 测试的关键技术。

DIP

让代码依赖于抽象而不是细节。

我们已经看到 DIP 是如何成为一个我们可以用来简化代码的主要工具。它允许我们编写处理接口的代码,然后使用这些代码与实现该接口的任何具体类。这引发了一个问题:我们能否编写一个实现接口但不会正确工作的类?这就是我们下一节的主题。

LSP – 可交换的对象

图灵奖获得者芭芭拉·利斯科夫是关于继承的规则的创造者,现在这个规则通常被称为 LSP。它是由面向对象中的一个问题引起的:如果我们可以扩展一个类并在其扩展的地方使用它,我们如何确保新的类不会破坏事物?

在上一节关于 DIP(依赖倒置原则)的讨论中,我们看到了如何使用实现接口的任何类来代替接口本身。我们还看到了这些类可以为该方法提供任何喜欢的实现。接口本身对实现代码内部可能存在的内容没有任何保证。

当然,这也存在一个不好的方面——这是 LSP 试图避免的。让我们通过查看代码中的反例来解释这一点。假设我们创建了一个新的类,它实现了interface Shape,如下所示(警告:不要在MaliciousShape类中运行以下代码!):

public class MaliciousShape implements Shape {
    @Override
    public void draw(Graphics g) {
        try {
            String[] deleteEverything = {"rm", "-Rf", "*"};
            Runtime.getRuntime().exec(deleteEverything,null);
            g.drawText("Nothing to see here...");
        } catch (Exception ex) {
            // No action
        }
    }
}

注意到那个新类有什么奇怪的地方吗?它包含一个 Unix 命令来删除所有我们的文件!当我们对一个形状对象调用draw()方法时,这并不是我们所期望的。由于权限失败,它可能无法删除任何东西,但这是一个可能出错的反例。

Java 中的接口只能保护我们期望的方法调用的语法。它不能强制执行任何语义。前面MaliciousShape类的问题在于它没有尊重接口背后的意图。

LSP 指导我们避免这种错误。换句话说,LSP 指出,任何实现接口或扩展另一个类的类必须处理原始类/接口可能的所有输入组合。它必须提供预期的输出,它必须不忽略有效的输入,并且它必须不产生完全意外和不期望的行为。这样的类可以通过它们的接口引用安全使用。我们MaliciousShape类的问题在于它与 LSP 不兼容——它添加了一些完全意外和不期望的行为。

LSP 正式定义

美国计算机科学家芭芭拉·利斯科夫提出了一个正式的定义:如果p(x)是关于类型T的对象x的可证明属性,那么p(y)对于类型S的对象y也应该为真,其中ST的子类型。

检查形状代码中的 LSP 使用情况

实现Shape的所有类都符合 LSP。这在TextBox类中很明显,如下所示:

public class TextBox implements Shape {
    private final String text;
    public TextBox(String text) {
        this.text = text;
    }
    @Override
    public void draw(Graphics g) {
        g.drawText(text);
    }
}

上述代码显然可以处理提供给其构造函数的任何有效文本。它也没有带来任何惊喜。它使用Graphics类的基本功能绘制文本,而不会做其他任何事情。

LSP(里氏替换原则)的合规性示例可以在以下类中看到:

  • Rectangle

  • Triangle

LSP

如果一个代码块可以安全地替换为另一个代码块,它可以处理完整的输入范围并提供(至少)所有预期的输出,而没有不期望的副作用。

有一些令人惊讶的 LSP 违反情况。也许对于形状代码示例的经典违反之一是关于添加Square类。在数学中,正方形是一种矩形,它有一个额外的约束,即其高度和宽度相等。在 Java 代码中,我们应该让Square类扩展Rectangle类吗?或者让Rectangle类扩展Square

让我们应用 LSP 来决定。我们将想象一些期望Rectangle类以便可以更改其高度但不能更改其宽度的代码。如果我们向该代码传递一个Square类,它是否会正常工作?答案是不会。那么你将得到一个宽度和高度不等的长方形。这违反了 LSP。

LSP 的要点是使类正确地符合接口。在下一节中,我们将探讨与 DI 密切相关 OCP。

OCP – 可扩展的设计

在本节中,我们将看到 OCP 如何帮助我们编写可以添加新功能而无需更改代码本身的代码。这听起来可能首先是不可能的,但它自然地从 DIP 与 LSP 的结合中产生。

OCP 导致代码对扩展开放但对修改封闭。当我们查看 DIP 时,我们看到了这个想法是如何工作的。让我们根据 OCP 回顾我们进行的代码重构。

让我们从Shapes类的原始代码开始,如下所示:

public class Shapes {
    private final List<Shape> allShapes = new ArrayList<>();
    public void add(Shape s) {
        allShapes.add(s);
    }
    public void draw(Graphics g) {
        for (Shape s : allShapes) {
            switch (s.getType()) {
                case "textbox":
                    var t = (TextBox) s;
                    g.drawText(t.getText());
                    break;
                case "rectangle":
                    var r = (Rectangle) s;
                    for (int row = 0;
                          row < r.getHeight();
                          row++) {
                        g.drawLine(0, r.getWidth());
                    }
            }
        }
    }
}

添加新的形状类型需要在draw()方法内部修改代码。我们将添加一个新的case语句来支持我们的新形状。

修改现有代码有几个缺点,如下所述:

  • 我们使之前的测试无效。现在的代码与之前测试的代码不同。

  • 我们可能引入一个错误,破坏了形状的一些现有支持。

  • 代码将变得更长,更难以阅读。

  • 我们可能有几个开发者同时添加形状,并在合并他们的工作时遇到合并冲突。

通过应用 DIP 和重构代码,我们最终得到了如下代码:

public class Shapes {
    private final List<Shape> all = new ArrayList<>();
    private final Graphics graphics;
    public Shapes(Graphics graphics) {
        this.graphics = graphics;
    }
    public void add(Shape s) {
        all.add(s);
    }
    public void draw() {
        all.forEach(shape->shape.draw(graphics));
    }
}

我们现在可以看到,添加新的形状类型不需要修改此代码。这是一个 OCP 在起作用的例子。Shapes类对新形状的定义是开放的,但当添加新形状时,它是封闭对修改的需求。这也意味着与Shapes类相关的任何测试都将保持不变,因为没有这个类的行为差异。这是一个强大的优势。

OCP 依赖于 DI 来工作。这基本上是对应用 DIP 的一个后果的重申。它还为我们提供了一种支持可替换行为的技术。我们可以使用 DIP 和 OCP 来创建插件系统。

添加新的形状类型

为了了解这在实践中是如何工作的,让我们创建一个新的形状类型,即RightArrow类,如下所示:

public class RightArrow implements Shape {
  public void draw(Graphics g) {
    g.drawText( "   \" );
    g.drawText( "-----" );
    g.drawText( "   /" );
  }
}

RightArrow类实现了Shape接口并定义了一个draw()方法。为了证明在使用此方法时Shapes类中没有任何东西需要改变,让我们回顾一些同时使用Shapes和我们的新类RightArrow的代码,如下所示:

package shapes;
public class ShapesExample {
    public static void main(String[] args) {
        new ShapesExample().run();
    }
    private void run() {
        Graphics console = new ConsoleGraphics();
        var shapes = new Shapes(console);
        shapes.add(new TextBox("Hello!"));
        shapes.add(new Rectangle(32,1));
        shapes.add(new RightArrow());
        shapes.draw();
    }
}

我们看到Shapes类被以完全正常的方式使用,没有任何改变。实际上,要使用我们新的RightArrow类,唯一需要改变的是创建一个对象实例并将其传递给Shapes类的add()方法。

OCP

使代码对新行为开放,但对修改封闭。

OCP 的力量现在应该很清楚了。我们可以扩展我们代码的功能,同时保持更改有限。我们大大降低了破坏现有工作代码的风险,因为我们不再需要更改该代码。OCP 是管理复杂性的好方法。在下一节中,我们将探讨剩下的 SOLID 原则:ISP。

ISP – 有效的接口

在本节中,我们将探讨一个帮助我们编写有效接口的原则。它被称为 ISP。

ISP 建议我们保持我们的接口小而专注于实现单一责任。通过小接口,我们指的是在任何单个接口上尽可能少的方法。这些方法都应该与某个共同的主题相关。

我们可以看出,这个原则实际上只是 SRP 的另一种形式。我们说的是,一个有效的接口应该描述一个单一的责任。它应该覆盖一个抽象,而不是几个。接口上的方法应该紧密相关,并且也与那个单一抽象相关。

如果我们需要更多的抽象,那么我们就使用更多的接口。我们将每个抽象保持在其自己的单独接口中,这就是术语接口分离的来源——我们将不同的抽象分开。

与此相关的代码异味是一个覆盖一个主题的多个不同主题的大接口。我们可以想象一个接口有数百个方法分成小群组——一些与文件管理相关,一些关于编辑文档,还有一些关于打印文档。这样的接口很快就会变得难以操作。ISP 建议我们通过将接口拆分为几个更小的接口来改进这一点。这种拆分将保留方法组——因此,你可能会看到文件管理、编辑和打印的接口,每个接口下都有相关的方法。通过将这些单独的抽象分开,我们已经使代码更容易理解。

在 shapes 代码中回顾 ISP 的使用

ISP 最明显的用途是在Shape接口中,如下所示:

interface Shape {
  void draw(Graphics g);
}

这个接口显然有一个单一的关注点。它是一个具有非常狭窄关注点的接口,以至于只需要指定一个方法:draw()。这里没有因其他混合概念而产生的混淆,也没有不必要的其他方法。这个单一的方法既是必要的也是充分的。另一个主要示例是在Graphics接口中,如下所示:

public interface Graphics {
    void drawText(String text);
    void drawHorizontalLine(int width);
}

Graphics接口只包含与在屏幕上绘制图形原语相关的方法。它有两个方法——drawText用于显示文本字符串,以及drawHorizontalLine用于绘制水平方向的线条。由于这些方法紧密相关——技术上称为具有高内聚性——并且数量很少,ISP 得到了满足。这是针对我们目的的图形绘制子系统的有效抽象。

为了完整性,我们可以以多种方式实现这个接口。GitHub 中的示例使用了一个简单的文本控制台实现:

public class ConsoleGraphics implements Graphics {
    @Override
    public void drawText(String text) {
        print(text);
    }
    @Override
    public void drawHorizontalLine(int width) {
        var rowText = new StringBuilder();
        for (int i = 0; i < width; i++) {
            rowText.append('X');
        }
        print(rowText.toString());
    }
    private void print(String text) {
        System.out.println(text);
    }
}

该实现也符合 LSP 规范——它可以在期望Graphics接口的任何地方使用。

ISP

保持接口小且与单一理念紧密相关。

我们现在已经涵盖了所有五个 SOLID 原则,并展示了它们是如何应用于形状代码的。它们指导设计走向紧凑的代码,拥有良好的工程结构以帮助未来的维护者。我们知道如何将这些建议纳入我们的代码以获得类似的好处。

摘要

在本章中,我们探讨了 SOLID 原则如何帮助我们设计生产代码和测试的简单解释。我们通过一个使用所有五个 SOLID 原则的示例设计进行了工作。在未来工作中,我们可以应用 SRP 来帮助我们理解设计并限制未来更改中涉及的重工作业。我们可以应用 DIP 将代码拆分成独立的小块,让每个块隐藏我们整体程序的一些细节,从而产生分而治之的效果。使用 LSP,我们可以创建可以安全且容易互换的对象。OCP 帮助我们设计易于添加功能的软件。ISP 将保持我们的接口小且易于理解。

下一章将把这些原则应用于解决测试中的问题——我们如何测试对象之间的协作?

问题和答案

  1. SOLID 原则仅适用于面向对象(OO)代码吗?

不。虽然最初是应用于面向对象(OO)的上下文,但它们在函数式编程和微服务设计中也有用途。单一职责原则(SRP)几乎在所有情况下都非常有用——坚持一个主要焦点对任何事情都有帮助,即使是文档段落。SRP 的思维方式也有助于我们编写只做一件事的纯函数和只做一件事的测试。依赖倒置原则(DIP)和开闭原则(OCP)在函数式上下文中很容易实现,通过将依赖项作为纯函数传递,就像我们使用 Java lambdas 那样。SOLID 作为一个整体,为管理任何类型软件组件之间的耦合和内聚提供了一套目标。

  1. 我们必须使用 SOLID 原则与 TDD 结合使用吗?

不。TDD 通过定义软件组件的结果和公共接口来工作。我们如何实现该组件对 TDD 测试来说无关紧要,但使用 SRP 和 DIP 等原则可以使编写针对该代码的测试变得容易,因为它为我们提供了所需的测试访问点。

  1. SOLID 原则是我们唯一应该使用的原则吗?

不。我们应该使用我们所能使用的每一种技术。

SOLID 原则在塑造代码方面是一个很好的起点,我们应该充分利用它们,但还有许多其他有效的技术可以设计软件。整个设计模式目录、克雷格·拉尔曼(Craig Larman)的通用责任分配软件模式(GRASP)的优秀体系、大卫·L·帕纳斯(David L. Parnas)的信息隐藏理念以及耦合和内聚的理念都适用。我们应该使用我们所知道或可以学习的任何和所有技术来服务于我们使软件易于阅读和安全的改变的目标。

  1. 如果我们不使用 SOLID 原则,我们还能做 TDD 吗?

是的——非常相关。TDD 关注的是测试代码的行为,而不是它如何实现的细节。SOLID 原则仅仅帮助我们创建出健壮且易于测试的面向对象设计。

  1. SRP 如何与 ISP 相关?

ISP 指导我们更倾向于使用多个较短的接口而不是一个大的接口。每个较短的接口应该与类应该提供的一个单一方面相关。这通常是一种角色,或者可能是一个子系统。ISP 可以被看作是确保我们的每个接口都应用了 SRP 并且只做一件事——做好。

  1. OCP 如何与 DIP 和 LSP 相关?

OCP 指导我们创建可以添加新功能而不改变组件本身的软件组件。这是通过使用插件设计来实现的。该组件将允许插入不同的类以提供新功能。这样做的方法是在接口中创建一个插件应该做什么的抽象——DIP。然后,创建符合 LSP 的具体插件实现。之后,我们可以将这些新插件注入到我们的组件中。OCP 依赖于 DIP 和 LSP 来工作。

第八章:测试替身——存根和模拟

在本章中,我们将解决一个常见的测试挑战。如何测试依赖于另一个对象的对象?如果那个协作者难以设置测试数据,我们该怎么办?有几种技术可以帮助我们做到这一点,并且它们建立在之前学到的 SOLID 原则之上。我们可以使用依赖注入的概念,使我们能够用专门编写来帮助我们编写测试的对象替换协作对象。

这些新对象被称为测试替身,在本章中我们将了解两种重要的测试替身。我们将学习何时应用每种类型的测试替身,然后学习两种在 Java 中创建它们的方法——既可以通过自己编写代码,也可以使用流行的库 Mockito。到本章结束时,我们将拥有允许我们为难以或无法使用真实协作对象进行测试的对象编写测试的技术。这使我们能够在复杂系统中使用 TDD。

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

  • 测试协作者的问题

  • 测试替身的目的

  • 使用存根来获取预定义的结果

  • 使用模拟来验证交互

  • 理解何时使用测试替身是合适的

  • 使用 Mockito——一个流行的模拟库

  • 使用存根驱动错误处理代码

  • 在 Wordz 中测试错误条件

技术要求

本章的代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter08找到。

协作者提出的测试问题

在本节中,我们将了解随着我们的软件发展成为更大的代码库时出现的挑战。我们将回顾协作对象的意义,然后我们将探讨两个具有挑战性的测试合作示例。

随着我们的软件系统的发展,我们很快就会超出单个类(或者函数)所能容纳的内容。我们将把代码分成多个部分。如果我们选择一个对象作为测试对象,那么它所依赖的任何其他对象都是协作者。我们的 TDD 测试必须考虑到这些协作者的存在。有时,这很简单,就像我们在前面的章节中看到的那样。

不幸的是,事情并不总是那么简单。有些协作使测试变得困难——或者不可能——编写。这类协作者引入了我们必须应对的不可重复行为,或者呈现难以触发的错误。

让我们通过一些简短的例子来回顾这些挑战。我们将从一个常见问题开始:一个表现出不可重复行为的协作者。

测试不可重复行为的挑战

我们已经了解到 TDD 测试的基本步骤是安排(Arrange)、行动(Act)和断言(Assert)。我们要求对象执行操作,然后断言预期的结果发生。但是,当结果不可预测时会发生什么呢?

为了说明这一点,让我们回顾一个掷骰子并显示文本字符串来说明我们掷了什么数字的类:

package examples;
import java.util.random.RandomGenerator;
public class DiceRoll {
    private final int NUMBER_OF_SIDES = 6;
    private final RandomGenerator rnd =
                       RandomGenerator.getDefault();
    public String asText() {
        int rolled = rnd.nextInt(NUMBER_OF_SIDES) + 1;
        return String.format("You rolled a %d", rolled);
    }
}

这段代码很简单,只有几行可执行代码。遗憾的是,编写简单并不意味着测试简单。我们该如何为这个编写测试?具体来说——我们该如何编写断言?在之前的测试中,我们总是确切地知道在断言中期望什么。在这里,断言将是一些固定文本加上一个随机数。我们事先不知道那个随机数会是什么。

测试错误处理的挑战

测试处理错误条件的代码是另一个挑战。这里的困难不在于断言错误已被处理,而挑战在于如何在协作对象内部触发该错误发生。

为了说明这一点,让我们想象一段代码,当我们的便携式设备中的电池电量低时,会提醒我们:

public class BatteryMonitor {
    public void warnWhenBatteryPowerLow() {
        if (DeviceApi.getBatteryPercentage() < 10) {
            System.out.println("Warning - Battery low");
        }
    }
}

BatteryMonitor中的前一段代码包含一个DeviceApi类,这是一个库类,它允许我们读取手机上剩余的电量。它提供了一个静态方法来完成这个操作,称为getBatteryPercentage()。这将返回一个介于0100百分比的整数。我们想要为编写 TDD 测试的代码调用getBatteryPercentage(),如果它小于10百分比,将显示警告信息。但编写这个测试有一个问题:我们如何在我们的安排步骤中强制getBatteryPercentage()方法返回一个小于 10 的数字?我们会以某种方式放电电池吗?我们该如何做?

BatteryMonitor提供了一个例子,展示了与另一个对象协作的代码,其中我们无法强制该协作者给出已知响应。我们没有方法改变getBatteryPercentage()将返回的值。我们实际上必须等到电池放电后,这个测试才能通过。这并不是 TDD 的宗旨。

理解为什么这些协作具有挑战性

在进行 TDD(测试驱动开发)时,我们希望测试是快速可重复的。任何涉及不可预测行为或需要我们控制我们无法控制的情况的场景,都会给 TDD 带来明显的问题。

在这些情况下编写测试的最佳方式是消除困难的根源。幸运的是,有一个简单的解决方案。我们可以应用我们在上一章中学到的依赖注入原则,以及一个新想法——测试替身。我们将在下一节中回顾测试替身。

测试替身的目的

在本节中,我们将学习允许我们测试这些具有挑战性的协作的技术。我们将介绍测试替身的概念。我们将学习如何将 SOLID 原则应用于设计足够灵活的代码,以便使用这些测试替身。

通过使用测试替身解决了上一节中的挑战。测试替身替换了我们测试中的协作对象之一。按照设计,这个测试替身避免了被替换对象的困难。想想电影中的替身,他们取代了真正的演员,以帮助安全地拍摄动作镜头。

软件测试替身是我们专门编写的一个对象,以便在单元测试中易于使用。在测试中,我们在安排步骤中将我们的测试替身注入到 SUT 中。在生产代码中,我们注入了测试替身所取代的生产对象。

让我们重新考虑之前的DiceRoll示例。我们将如何重构这段代码以使其更容易测试?

  1. 创建一个抽象随机数来源的接口:

    interface RandomNumbers {
    
        int nextInt(int upperBoundExclusive);
    
    }
    
  2. 依赖倒置原则应用于class DiceRoll以利用这个抽象:

    package examples;
    
    import java.util.random.RandomGenerator;
    
    public class DiceRoll {
    
        private final int NUMBER_OF_SIDES = 6;
    
        private final RandomNumbers rnd ;
    
        public DiceRoll( RandomNumbers r ) {
    
            this.rnd = r;
    
        }
    
        public String asText() {
    
            int rolled = rnd.nextInt(NUMBER_OF_SIDES) + 1;
    
            return String.format("You rolled a %d",
    
                                 rolled);
    
        }
    
    }
    

我们通过用RandomNumbers接口替换随机数生成器来反转了对随机数生成器的依赖。我们添加了一个构造函数,允许注入合适的RandomNumbers实现。我们将其分配给rnd字段。asText()方法现在调用我们传递给构造函数的任何对象的nextInt()方法。

  1. 编写一个使用测试替身替换RandomNumbers来源的测试:

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    class DiceRollTest {
    
        @Test
    
        void producesMessage() {
    
            var stub = new StubRandomNumbers();
    
            var roll = new DiceRoll(stub);
    
            var actual = roll.asText();
    
            assertThat(actual).isEqualTo("You rolled a
    
                                         5");
    
        }
    
    }
    

我们在这个测试中看到了常见的安排、行动和断言部分。这里的新想法是class StubRandomNumbers。让我们看看这个存根代码:

package examples;
public class StubRandomNumbers implements RandomNumbers {
    @Override
    public int nextInt(int upperBoundExclusive) {
        return 4;  // @see https://xkcd.com/221
    }
}

关于这个存根有一些需要注意的事情。首先,它实现了我们的RandomNumbers接口,使其成为该接口的 LSP 兼容替身。这允许我们将它注入到DiceRoll的构造函数中,我们的 SUT。第二个最重要的方面是,每次对nextInt()的调用都将返回相同的数字

通过用提供已知值的存根替换真实的RandomNumbers来源,我们已经使测试断言变得容易编写。存根消除了随机生成器不可重复值的问题。

我们现在可以看到DiceRollTest是如何工作的。我们向我们的 SUT 提供一个测试替身。测试替身总是返回相同的值。因此,我们可以对已知结果进行断言。

制作代码的生产版本

要使class DiceRoll在生产中正常工作,我们需要注入一个真正的随机数来源。一个合适的类可能是以下内容:

public class RandomlyGeneratedNumbers implements RandomNumbers {
    private final RandomGenerator rnd =
                       RandomGenerator.getDefault();
    @Override
    public int nextInt(int upperBoundExclusive) {
        return rnd.nextInt(upperBoundExclusive);
    }
}

这里没有太多工作要做——前面的代码只是简单地使用内置在 Java 中的RandomGenerator库类实现了nextInt()方法。

我们现在可以使用这个方法来创建我们的代码生产版本。我们已经将我们的DiceRoll类修改为允许我们注入任何适合的RandomNumbers接口的实现。对于我们的测试代码,我们注入了一个测试替身——StubRandomNumbers类的实例。对于我们的生产代码,我们将注入RandomlyGeneratedNumbers类的实例。生产代码将使用该对象来创建真实的随机数——并且DiceRoll类内部将不会有任何代码更改。我们已经使用了依赖倒置原则,通过依赖注入使DiceRoll类可配置。这意味着DiceRoll类现在遵循开放/封闭原则——它对新类型的随机数生成行为是开放的,但对类内部的代码更改是封闭的。

依赖倒置、依赖注入和控制反转

上述示例展示了这三个想法的实际应用。依赖倒置是一种设计技术,我们在代码中创建一个抽象。依赖注入是一种运行时技术,我们向依赖于它的代码提供该抽象的实现。这两个想法通常被称为控制反转IoC)。例如,Spring 这样的框架有时被称为 IoC 容器,因为它们提供了帮助您管理在应用程序中创建和注入依赖项的工具。

以下代码是我们在生产环境中使用DiceRollRandomlyGeneratedNumbers的示例:

public class DiceRollApp {
    public static void main(String[] args) {
        new DiceRollApp().run();
    }
    private void run() {
        var rnd = new RandomlyGeneratedNumbers();
        var roll = new DiceRoll(rnd);
        System.out.println(roll.asText());
    }
}

你可以从之前的代码中看到,我们将生产版本的RandomlyGeneratedNumbers类的实例注入到DiceRoll类中。创建和注入对象的过程通常被称为对象配置。例如,Springspring.io/)、Google Guicegithub.com/google/guice)和内置的Java CDIdocs.oracle.com/javaee/6/tutorial/doc/giwhl.html)等框架提供了使用注解来最小化创建依赖项和配置它们所需样板代码的方法。

使用依赖倒置原则来交换生产对象和测试替身是一个非常强大的技术。这个测试替身是一种称为存根的双面实例。在下一节中,我们将介绍存根是什么以及何时使用它。

使用存根进行预定义结果

上一节解释了测试替身是一种可以替代生产对象的物体,这样我们就可以更容易地编写测试。在本节中,我们将更详细地研究这个测试替身并对其进行泛化。

在先前的 DiceRoll 示例中,测试编写起来更简单,因为我们用已知、固定的值替换了随机数生成。我们的真实随机数生成器使得编写断言变得困难,因为我们从未确定预期的随机数应该是什么。我们的测试替身是一个提供已知值的对象。然后我们可以计算出断言的预期值,使测试易于编写。

提供此类值的测试替身被称为 存根。存根总是用我们可以控制的测试版本替换我们无法控制的对象。它们总是为我们的测试代码提供已知的数据值。图形上,存根看起来像这样:

图 8.1 – 用存根替换协作者

图 8.1 – 用存根替换协作者

在图中,我们的测试类负责在 Arrange 步骤中将我们的系统单元(SUT)连接到适当的存根对象。当 Act 步骤要求我们的 SUT 执行我们想要测试的代码时,该代码将从存根中拉取已知的数据值。Assert 步骤可以根据这些已知数据值将导致的行为来编写。

重要的是要注意为什么这行得通。对这个安排的反对意见之一是我们没有测试真实的系统。我们的 SUT 连接到一些永远不会成为我们生产系统一部分的对象。这是真的。但这是因为我们的测试只测试 SUT 内部的逻辑。这个测试不是测试依赖项的行为。实际上,它必须不尝试这样做。测试测试替身是单元测试的一个经典反模式。

我们的 SUT 已经使用依赖倒置原则完全隔离了自己,从存根所代表的对象中。对于 SUT 来说,它如何从其协作者那里获取数据没有区别。这就是为什么这种测试方法有效的原因。

何时使用存根对象

当我们的 SUT 使用与依赖项协作的 拉模型 时,存根是有用的。以下是一些使用存根有意义的例子:

  • 存根存储库接口/数据库:使用存根而不是调用真实数据库进行数据访问代码

  • 存根参考数据源:用存根数据替换包含参考数据的属性文件或网络服务

  • 为将代码转换为 HTML 或 JSON 格式的代码提供应用程序对象:当测试将代码转换为 HTML 或 JSON 时,用存根提供输入数据

  • 存根系统时钟以测试时间依赖行为:为了从时间调用中获得可重复的行为,用已知的时间存根调用

  • 存根随机数生成器以创建可预测性:用一个存根的调用替换对随机数生成器的调用

  • 存根认证系统以始终允许测试用户登录:用简单的“登录成功”存根替换对认证系统的调用

  • 从第三方网络服务(如支付提供者)中模拟响应:将调用第三方服务的真实调用替换为对模拟的调用

  • 模拟对操作系统命令的调用:用预制的模拟数据替换对操作系统调用,例如列出目录

在本节中,我们看到了如何使用存根来控制提供给 SUT 的数据。它支持从其他地方获取对象的拉模型。但这并不是唯一一种对象协作的机制。有些对象使用推模型。在这种情况下,当我们调用 SUT 上的方法时,我们期望它调用另一个对象上的方法。我们的测试必须确认这个方法调用确实发生了。这是存根无法帮助解决的问题,需要不同的方法。我们将在下一节中介绍这种方法。

使用模拟来验证交互

在本节中,我们将探讨另一种重要的测试替身:模拟对象。模拟对象解决的问题与存根对象略有不同,正如我们将在本节中看到的那样。

模拟对象是一种测试替身,它能够记录交互。与提供已知对象给 SUT 的存根不同,模拟对象将简单地记录 SUT 与模拟对象之间的交互。这是回答“SUT 是否正确调用了方法?”这一问题的完美工具。这解决了 SUT 与其协作者之间的推送模型交互问题。SUT 命令协作者做某事,而不是从它那里请求某物。模拟提供了一种验证它是否发出了该命令以及任何必要参数的方法。

下面的 UML 对象图显示了一般的安排:

图 8.2 – 用模拟替换协作者

图 8.2 – 用模拟替换协作者

我们看到测试代码将一个模拟对象连接到 SUT。执行步骤将使 SUT 执行我们期望与它的协作者交互的代码。我们已经用模拟对象替换了那个协作者,该模拟对象将记录某个方法被调用的事实。

让我们通过一个具体的例子来使这个问题更容易理解。假设我们的 SUT 预期向用户发送电子邮件。再次使用依赖倒置原则,将我们的邮件服务器抽象为一个接口:

public interface MailServer {
    void sendEmail(String recipient, String subject,
                   String text);
}

上述代码显示了一个简化的接口,仅适用于发送简短的文本电子邮件。对于我们的目的来说已经足够好了。为了测试调用此接口上的sendEmail()方法的 SUT,我们将编写一个MockMailServer类:

public class MockMailServer implements MailServer {
    boolean wasCalled;
    String actualRecipient;
    String actualSubject;
    String actualText;
    @Override
    public void sendEmail(String recipient, String subject,
                          String text) {
        wasCalled = true;
        actualRecipient = recipient;
        actualSubject = subject;
        actualText = text;
    }
}

之前的 MockMailServer 类实现了 MailServer 接口。它只有一个职责——记录 sendEmail() 方法被调用的事实,并捕获发送给该方法的实际参数值。它将这些值以包公共可见性的简单字段形式暴露出来。我们的测试代码可以使用这些字段来形成断言。我们的测试只需将这个模拟对象连接到系统单元(SUT),让系统单元执行我们期望调用 sendEmail() 方法的代码,然后检查它是否真的那样做了:

@Test
public void sendsWelcomeEmail() {
    var mailServer = new MockMailServer();
    var notifications = new UserNotifications(mailServer);
    notifications.welcomeNewUser();
    assertThat(mailServer.wasCalled).isTrue();
    assertThat(mailServer.actualRecipient)
         .isEqualTo("test@example.com");
    assertThat(mailServer.actualSubject)
         .isEqualTo("Welcome!");
    assertThat(mailServer.actualText)
         .contains("Welcome to your account");
}

我们可以看到,这个测试将模拟对象连接到我们的系统单元(SUT),然后让系统单元执行 welcomeNewUser() 方法。我们期望这个方法在 MailServer 对象上调用 sendEmail() 方法。然后,我们需要编写断言来确认调用确实使用了正确的参数值。我们在这里逻辑上使用了四个断言语句,测试了一个想法——实际上是一个单独的断言。

模拟对象的力量在于我们可以记录与难以控制的对象的交互。在邮件服务器的例子中,比如前面代码块中看到的,我们不想向任何人发送实际邮件。我们也不希望编写一个等待监控测试用户邮箱的测试。这不仅速度慢且可能不可靠,而且这也不是我们想要测试的内容。系统单元只负责调用 sendEmail() 方法——之后发生的事情超出了系统单元的职责范围。因此,这也超出了这个测试的范围。

就像之前其他测试替身的例子一样,我们使用了使用 SMTP 协议与真实邮件服务器通信的 MailServer。我们很可能会寻找一个库类来为我们完成这项工作,然后我们需要创建一个非常简单的适配器对象,将库代码绑定到我们的接口上。

本节已涵盖两种常见的测试替身类型,即存根和模拟。但测试替身并不总是适合使用。在下一节中,我们将讨论使用测试替身时应注意的一些问题。

理解何时使用测试替身是合适的

正如我们所见,模拟对象是一种有用的测试替身。但它们并不总是正确的做法。有些情况下,我们应该积极避免使用模拟。这些情况包括过度使用模拟、使用不属于你的代码的模拟,以及模拟值对象。我们将在下一节中探讨这些情况。然后,我们将总结一般性的建议,说明模拟通常在哪些情况下有用。让我们首先考虑过度使用模拟对象所引起的问题。

避免过度使用模拟对象

初看之下,使用模拟对象似乎能为我们解决许多问题。然而,如果不加注意地使用,我们可能会得到质量很差的测试。为了理解原因,让我们回顾一下 TDD 测试的基本定义。它是一种验证行为且与实现无关的测试。如果我们使用模拟对象来代替一个真正的抽象,那么我们就遵守了这一点。

潜在的问题发生是因为创建一个模拟对象来代替实现细节,而不是抽象,实在太容易了。如果我们这样做,我们最终会将我们的代码锁定在特定的实现和结构上。一旦测试与特定的实现细节耦合,那么更改该实现就需要更改测试。如果新的实现与旧的一个有相同的结果,那么测试实际上应该仍然通过。依赖于特定实现细节或代码结构的测试会积极阻碍重构和添加新功能。

不要模拟你拥有的代码

另一个不应该使用模拟的地方是作为你团队外部编写的具体类的替代品。假设我们正在使用一个名为PdfGenerator的库中的类来创建 PDF 文档。我们的代码会在PdfGenerator类上调用方法。我们可能会认为,如果我们使用模拟对象来代替PdfGenerator类,测试我们的代码会很容易。

这种方法有一个问题,这个问题可能只会出现在未来。外部库中的类很可能发生变化。比如说,PdfGenerator类移除了我们代码中调用的一种方法。如果我们不更新库版本,那么根据我们的安全策略,我们最终被迫更新库版本。当我们引入新版本时,我们的代码将无法编译这个已更改的类——但我们的测试仍然会通过,因为模拟对象中仍然有旧方法。这是我们为代码的未来维护者设置的微妙陷阱。最好避免这种情况。一个合理的方法是包装第三方库,理想情况下将其放在接口后面,以反转对它的依赖,完全隔离它。

不要模拟值对象

值对象是一个没有特定身份的对象,它只由包含的数据定义。一些例子包括整数或字符串对象。我们认为两个字符串相同,如果它们包含相同的文本。它们可能在内存中是两个不同的字符串对象,但如果它们持有相同的值,我们认为它们是相等的。

在 Java 中,一个对象是值对象的线索是存在自定义的equals()hashCode()方法。默认情况下,Java 使用对象的身份来比较两个对象的相等性——它检查两个对象引用是否指向内存中的相同对象实例。我们必须重写equals()hashCode()方法,以根据其内容为值对象提供正确的行为。

值对象是一个简单的东西。它可能在方法内部有一些复杂的行为,但原则上,值对象应该很容易创建。创建一个模拟对象来代替这些值对象没有任何好处。相反,创建值对象并在测试中使用它。

没有依赖注入就无法模拟

测试替身只能在我们可以注入它们的地方使用。这并不总是可能的。如果我们想要测试的代码使用new关键字创建了一个具体的类,那么我们就不能用它来替换一个替身:

package examples;
public class UserGreeting {
    private final UserProfiles profiles
        = new UserProfilesPostgres();
    public String formatGreeting(UserId id) {
        return String.format("Hello and welcome, %s",
                profiles.fetchNicknameFor(id));
    }
}

我们看到profiles字段已经使用具体的类UserProfilesPostgres()初始化。没有直接的方法可以注入测试替身。我们可以尝试使用 Java 反射来解决这个问题,但最好将其视为 TDD 对我们设计局限性的反馈。解决方案是允许依赖注入,正如我们在之前的例子中所看到的。

这通常是遗留代码的问题,这种代码是在我们工作之前编写的。如果这段代码创建了具体的对象——而且代码无法更改——那么我们就不能应用测试替身。

不要测试模拟

测试模拟是一个用来描述测试中在测试替身中构建了太多假设的短语。假设我们编写了一个存根来代替某些数据库访问,但这个存根包含数百行代码来模拟对数据库的详细特定查询。当我们编写测试断言时,它们都将基于我们在存根中模拟的这些详细查询。

那种方法将证明 SUT 逻辑对这些查询有响应。但我们的存根现在对真实数据访问代码的工作方式做出了很多假设。存根代码和真实数据访问代码可能会很快失去同步。这会导致一个无效的单元测试通过,但带有存根响应,这些响应在现实中已不再可能发生。

何时使用模拟对象

当我们的 SUT 使用推送模型并从其他组件请求操作时,其中没有明显的响应,模拟就很有用:

  • 从远程服务请求操作,例如向邮件服务器发送电子邮件

  • 从数据库中插入或删除数据

  • 通过 TCP 套接字或串行接口发送命令

  • 使缓存失效

  • 将日志信息写入日志文件或分发日志端点

在本节中,我们学习了一些技术,这些技术允许我们验证是否请求了某个操作。我们看到了如何再次使用依赖倒置原则来允许我们注入一个可以查询的测试替身。我们还看到了一个手动编写代码的例子。但我们是否必须总是手动编写测试替身?在下一节中,我们将介绍一个非常有用的库,它可以为我们做大部分工作。

使用 Mockito – 一个流行的模拟库

前面的章节展示了使用存根和模拟来测试代码的示例。我们一直手动编写这些测试双胞胎。显然,这样做非常重复且耗时。这引发了一个问题:这种重复的样板代码是否可以自动化?幸运的是,它可以。本节将回顾在流行的 Mockito 库中可用的帮助。

Mockito 是一个免费的开源库,遵循 MIT 许可证。这个许可证意味着我们可以在商业开发工作中使用它,前提是我们工作的人同意。Mockito 提供了大量的功能,旨在用很少的代码创建测试双胞胎。Mockito 网站可在 site.mockito.org/ 找到。

开始使用 Mockito

开始使用 Mockito 很简单。我们在 Gradle 文件中引入 Mockito 库和一个扩展库。扩展库允许 MockitoJUnit5 紧密集成。

build.gradle 的摘录看起来像这样:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.mockito:mockito-core:4.8.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
}

使用 Mockito 编写存根

让我们看看 Mockito 如何帮助我们创建存根对象。我们将使用 TDD 来创建一个 UserGreeting 类,该类在从 interface UserProfiles 获取昵称后提供个性化的问候。

让我们分小步骤来写,看看 TDD 和 Mockito 如何协同工作:

  1. 编写基本的 JUnit5 测试类并将其与 Mockito 集成:

    package examples
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
    }
    

@ExtendWith(MockitoExtension.class) 标记这个测试使用 Mockito。当我们运行这个 JUnit5 测试时,该注解确保运行 Mockito 库代码。

  1. 添加一个测试来确认预期的行为。我们将通过断言来捕捉这一点:

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
        @Test
    
        void formatsGreetingWithName() {
    
            String actual = «»;
    
            assertThat(actual)
    
               .isEqualTo("Hello and welcome, Alan");
    
        }
    
    }
    

这是我们之前看到的标准使用 JUnitAssertJ 框架的方法。如果我们现在运行这个测试,它将会失败。

  1. 使用 Act 步骤驱动我们的 SUT(我们想要编写的类):

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
        private static final UserId USER_ID
    
            = new UserId("1234");
    
        @Test
    
        void formatsGreetingWithName() {
    
            var greeting = new UserGreeting();
    
            String actual =
    
                greeting.formatGreeting(USER_ID);
    
            assertThat(actual)
    
                .isEqualTo("Hello and welcome, Alan");
    
        }
    
    }
    

下面的步骤展示了如何驱动出两个新的生产代码类。

  1. 添加一个 UserGreeting 类的骨架:

    package examples;
    
    public class UserGreeting {
    
        public String formatGreeting(UserId id) {
    
            throw new UnsupportedOperationException();
    
        }
    
    }
    

如同往常,我们只添加必要的代码来使测试编译。这里捕捉到的设计决策显示,我们的行为是由一个 formatGreeting() 方法提供的,该方法通过 UserId 类识别用户。

  1. 添加一个 UserId 类的骨架:

    package examples;
    
    public class UserId {
    
        public UserId(String id) {
    
        }
    
    }
    

再次,我们只添加了使测试编译的空壳。然后,我们运行测试,它仍然会失败:

图 8.3 – 测试失败

图 8.3 – 测试失败

  1. 另一个需要捕捉的设计决策是,UserGreeting 类将依赖于 UserProfiles 接口。我们需要创建一个字段,创建接口骨架,并在 SUT 的新构造函数中注入该字段:

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
        private static final UserId USER_ID
    
            = new UserId("1234");
    
        private UserProfiles profiles;
    
        @Test
    
        void formatsGreetingWithName() {
    
            var greeting
    
                = new UserGreeting(profiles);
    
            String actual =
    
                greeting.formatGreeting(USER_ID);
    
            assertThat(actual)
    
                .isEqualTo("Hello and welcome, Alan");
    
        }
    
    }
    

我们继续添加最少的代码来使测试编译。如果我们运行测试,它仍然会失败。但我们已经取得了进一步进展,所以现在的失败是一个 UnsupportedOperationException 错误。这证实了 formatGreeting() 已经被调用:

图 8.4 – 方法调用失败确认

图 8.4 – 失败确认方法调用

  1. formatGreeting()方法添加行为:

    package examples;
    
    public class UserGreeting {
    
        private final UserProfiles profiles;
    
        public UserGreeting(UserProfiles profiles) {
    
            this.profiles = profiles;
    
        }
    
        public String formatGreeting(UserId id) {
    
            return String.format("Hello and Welcome, %s",
    
                    profiles.fetchNicknameFor(id));
    
        }
    
    }
    
  2. fetchNicknameFor()添加到UserProfiles接口中:

    package examples;
    
    public interface UserProfiles {
    
        String fetchNicknameFor(UserId id);
    
    }
    
  3. 运行测试。它将因空指针异常而失败:

图 8.5 – 空指针异常失败

图 8.5 – 空指针异常失败

测试失败是因为我们将profiles字段作为依赖传递给了我们的系统单元(SUT),但该字段从未被初始化。这就是 Mockito 发挥作用的地方(终于)。

  1. @Mock注解添加到profiles字段:

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.Mock;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
        private static final UserId USER_ID = new     UserId("1234");
    
    @Mock
    
        private UserProfiles profiles;
    
        @Test
    
        void formatsGreetingWithName() {
    
            var greeting = new UserGreeting(profiles);
    
            String actual =
    
                   greeting.formatGreeting(USER_ID);
    
            assertThat(actual)
    
                    .isEqualTo("Hello and welcome, Alan");
    
        }
    
    }
    

现在运行测试会产生不同的失败,因为我们还没有配置 Mockito 的 mock:

图 8.6 – 添加了 mock,但未配置

图 8.6 – 添加了 mock,但未配置

  1. 配置@Mock以返回正确的存根数据供测试使用:

    package examples;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.Mock;
    
    import org.mockito.Mockito;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    
    public class UserGreetingTest {
    
        private static final UserId USER_ID = new     UserId("1234");
    
        @Mock
    
        private UserProfiles profiles;
    
        @Test
    
        void formatsGreetingWithName() {
    
            when(profiles.fetchNicknameFor(USER_ID))
    
               .thenReturn("Alan");
    
            var greeting = new UserGreeting(profiles);
    
            String actual =
    
                   greeting.formatGreeting(USER_ID);
    
            assertThat(actual)
    
                    .isEqualTo("Hello and welcome, Alan");
    
        }
    
    }
    
  2. 如果你再次运行测试,它将因问候文本中的错误而失败。修复这个问题然后重新运行测试,它将通过:

图 8.7 – 测试通过

图 8.7 – 测试通过

我们刚刚创建了class UserGreeting,它通过interface UserProfiles访问用户的某些存储昵称。该接口使用了 DIP(依赖倒置原则)来隔离UserGreeting与存储实现的任何具体细节。我们使用存根实现来编写测试。我们遵循了 TDD(测试驱动开发)并利用 Mockito 为我们编写了这个存根。

你还会注意到在最后一步测试失败了。我预期这一步应该通过。它没有通过是因为我错误地输入了问候信息。再次,TDD 救了我。

使用 Mockito 编写 mock

Mockito 可以像创建存根一样轻松地创建 mock 对象。我们仍然可以在希望成为 mock 的字段上使用@Mock注解——也许最后终于理解了这个注解。我们使用 Mockito 的verify()方法来检查我们的 SUT 是否正确地调用了一个协作者的预期方法。

让我们看看如何使用 mock。我们将为一些预期通过MailServer发送电子邮件的 SUT 代码编写一个测试:

@ExtendWith(MockitoExtension.class)
class WelcomeEmailTest {
    @Mock
    private MailServer mailServer;
    @Test
    public void sendsWelcomeEmail() {
        var notifications
                 = new UserNotifications( mailServer );
        notifications.welcomeNewUser("test@example.com");
        verify(mailServer).sendEmail("test@example.com",
                "Welcome!",
                "Welcome to your account");
    }
}

在这个测试中,我们看到@ExtendWith(MockitoExtension.class)注解用于初始化 Mockito,以及我们测试方法中熟悉的安排(Arrange)、行动(Act)和断言(Assert)格式。这里的新想法在于断言。我们使用 Mockito 库中的verify()方法来检查我们的 SUT 是否正确地调用了sendEmail()方法。检查还验证了它是否使用了正确的参数值。

Mockito 使用代码生成来实现所有这些。它包装了我们用@Mock注解标记的接口,并拦截每个调用。它为每个调用存储参数值。当我们使用verify()方法来确认方法是否正确调用时,Mockito 拥有完成这项工作所需的所有数据。

警惕 Mockito 的 when()和 verify()语法!

Mockito 对when()verify()的语法有细微的差别:

  • when().thenReturn(expected value);

  • verify().method();

模糊存根和 mock 之间的区别

关于 Mockito 术语的一个需要注意的事项是,它模糊了存根和模拟对象之间的区别。在 Mockito 中,我们创建被标记为模拟对象的测试替身。但在我们的测试中,我们可以将这些替身用作存根、模拟,甚至两者的混合体。

将测试替身配置为既是存根又是模拟是一种测试代码的坏味道。这并不错,但值得停下来思考。我们应该考虑我们既模拟又存根的协作者是否混淆了一些职责。将那个对象拆分出来可能是有益的。

参数匹配器 - 定制测试替身的行为

到目前为止,我们已经配置了 Mockito 测试替身来响应它们所替代的方法的非常具体的输入。之前的 MailServer 示例检查了三个特定的参数值是否传递给了sendEmail()方法调用。但有时我们希望在测试替身中拥有更多的灵活性。

Mockito 提供了一些称为参数匹配器的库方法。这些是在when()verify()语句内部使用的静态方法。参数匹配器用于指示 Mockito 对可能传递给被测试方法的参数值范围(包括 null 和未知值)做出响应。

以下测试使用了一个接受任何UserId值的参数匹配器:

package examples2;
import examples.UserGreeting;
import examples.UserId;
import examples.UserProfiles;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class UserGreetingTest {
    @Mock
    private UserProfiles profiles;
    @Test
    void formatsGreetingWithName() {
      when(profiles.fetchNicknameFor(any()))
          .thenReturn("Alan");
        var greeting = new UserGreeting(profiles);
        String actual =
          greeting.formatGreeting(new UserId(""));
        assertThat(actual)
          .isEqualTo("Hello and welcome, Alan");
    }
}

我们已经将any()参数匹配器添加到了fetchNicknameFor()方法的存根中。这指示 Mockito 无论传递给fetchNicknameFor()的参数值是什么,都返回预期的值Alan。这在编写测试以引导读者并帮助他们理解特定测试中什么重要、什么不重要时非常有用。

Mockito 提供了一系列参数匹配器,这些匹配器在 Mockito 官方文档中有描述。这些参数匹配器在创建用于模拟错误条件的存根时特别有用。这是下一节的主题。

使用测试驱动错误处理代码

在本节中,我们将探讨存根对象的一个很好的用途,即它们在测试错误条件中的作用。

在我们编写代码的过程中,我们需要确保它能够很好地处理错误条件。一些错误条件很容易测试。一个例子可能是一个用户输入验证器。为了测试它处理无效数据引起的错误,我们只需编写一个测试,向它提供无效数据,然后编写一个断言来检查它是否成功报告了数据无效。但使用它的代码怎么办呢?

如果我们的系统单元(SUT)是响应其协作者引发的错误条件的代码,我们需要测试那个错误响应。我们如何测试它取决于我们选择报告错误的方式。我们可能使用一个简单的状态码,在这种情况下,从存根返回那个错误代码将非常有效。

我们还可能选择使用 Java 异常来报告这个错误。异常是有争议的。如果误用,它们可能导致代码中的控制流非常不清楚。然而,我们需要知道如何测试它们,因为它们出现在几个 Java 库和内部编码风格中。幸运的是,编写异常处理代码的测试并不困难。

我们从创建一个存根开始,可以使用本章中介绍的任何方法。然后我们需要安排存根在调用方法时抛出适当的异常。Mockito 有一个很好的功能可以实现这一点,所以让我们看看一个使用异常的示例 Mockito 测试:

    @Test
    public void rejectsInvalidEmailRecipient() {
        doThrow(new IllegalArgumentException())
            .when(mailServer).sendEmail(any(),any(),any());
        var notifications
            = new UserNotifications( mailServer );
        assertThatExceptionOfType(NotificationFailureException.class)
                .isThrownBy(()->notifications
                    .welcomeNewUser("not-an-email-address"));
    }

在这个测试的开始阶段,我们使用 Mockito 的 doThrow() 方法来配置我们的模拟对象。这会将 Mockito 模拟对象 mailServer 配置为在调用 sendEmail() 方法时抛出 IllegalArgumentException 异常,无论我们发送什么参数值。这反映了设计决策,即通过抛出该异常作为报告电子邮件地址无效的机制。当我们的系统单元测试(SUT)调用 mailServer.sendEmail() 方法时,该方法将抛出 IllegalArgumentExeption 异常。我们可以练习处理这个异常的代码。

对于这个例子,我们决定让 SUT 包装并重新抛出 IllegalArgumentException。我们选择创建一个与用户通知责任相关的新异常。我们将称之为 NotificationFailureException。测试的断言步骤使用 AssertJ 库的特性 assertThatExceptionOfType()。这同时执行了 Act 和 Assert 步骤。我们调用我们的 SUT welcomeNewUser() 方法,并断言它抛出了我们的 NotificationFailureException 错误。

我们可以看到这足以触发我们的 SUT 代码中的异常处理响应。这意味着我们可以先编写测试,然后驱动出所需的代码。我们编写的代码将包括一个用于处理 InvalidArgumentException 的捕获处理程序。在这种情况下,所有新的代码必须做的就是抛出一个 NotificationFailureException 错误。这是一个我们将创建的新类,它继承自 RuntimeException。我们这样做是为了通过发送通知来报告出错了。作为正常系统分层考虑的一部分,我们希望用更通用的异常替换原始异常,这对于这一层代码来说更为合适。

本节探讨了 Mockito 和 AssertJ 库的特性,这些特性帮助我们使用 TDD 来驱动异常处理行为。在下一节中,我们将将这些内容应用到 Wordz 应用程序中的错误处理上。

测试 Wordz 中的错误条件

在本节中,我们将通过编写一个测试来应用我们所学的知识,该测试将为玩家选择一个随机单词进行猜测,从存储的单词集中。我们将创建一个名为WordRepository的接口来访问存储的单词。我们将通过一个fetchWordByNumber(wordNumber)方法来实现,其中wordNumber标识一个单词。在这里的设计决策是,每个单词都存储了一个从1开始的顺序号,以帮助我们随机选择一个。

我们将编写一个WordSelection类,该类负责选择一个随机数,并使用该数从存储中检索带有该数字的单词。我们将使用之前提到的RandomNumbers接口。对于这个例子,我们的测试将覆盖尝试从WordRepository接口中检索单词,但出于某种原因,它不在那里的情况。

我们可以按如下方式编写测试:

@ExtendWith(MockitoExtension.class)
public class WordSelectionTest {
    @Mock
    private WordRepository repository;
    @Mock
    private RandomNumbers random;
    @Test
    public void reportsWordNotFound() {
        doThrow(new WordRepositoryException())
                .when(repository)
                  .fetchWordByNumber(anyInt());
        var selection = new WordSelection(repository,
                                          random);
        assertThatExceptionOfType(WordSelectionException.class)
                .isThrownBy(
                        ()->selection.getRandomWord());
    }
}

测试捕捉了更多与如何设计WordRepositoryWordSelection工作相关的决策。我们的fetchWordByNumber(wordNumber)存储库方法在检索单词时遇到任何问题都会抛出WordRepositoryException。我们的意图是让WordSelection抛出它自己的自定义异常来报告它无法完成getRandomWord()请求。

为了在测试中设置这种情况,我们首先安排存储库抛出异常。这是通过使用 Mockito 的doThrow()功能来完成的。每当调用fetchWordByNumber()方法时,Mockito 都会抛出我们请求它抛出的异常,即WordRepositoryException。这允许我们驱动出处理这种错误条件的代码。

我们的“安排”步骤是通过创建WordSelection SUT 类来完成的。我们将两个协作者传递给构造函数:WordRepository实例和一个RandomNumbers实例。我们已经要求 Mockito 通过在测试双重的repositoryrandom字段上添加@Mock注解来为这两个接口创建存根。

现在 SUT 已经正确构建,我们准备编写测试的“执行”和“断言”步骤。我们正在测试是否抛出异常,因此我们需要使用assertThatExceptionOfType() AssertJ 功能来完成。我们可以传递我们期望抛出的异常的类,即WordSelectionException。我们将isThrownBy()方法链接到执行步骤并运行 SUT 代码。这作为 Java lambda 函数的参数传递给isThrownBy()方法。这将调用getRandomWord()方法,我们希望它失败并抛出异常。断言将确认这已经发生,并且已经抛出了预期的异常类。我们将运行测试,看到它失败,然后添加必要的逻辑使测试通过。

测试代码向我们展示了我们可以使用测试双倍和错误条件验证与测试驱动开发(TDD)相结合。它还表明,测试可以轻易地与解决方案的特定实现耦合。在这个测试中有很多设计决策,包括哪些异常会发生以及它们在哪里被使用。这些决策甚至包括使用异常来报告错误的事实。尽管如此,这仍然是一种合理的方式来划分责任和定义组件之间的契约。所有这些都包含在测试中。

摘要

在本章中,我们探讨了如何解决测试有问题的协作者的问题。我们学习了如何使用称为测试双倍的替代对象来测试协作者。我们了解到,这使我们能够简单地控制那些协作者在测试代码中的行为。

两种测试双倍(test double)对我们特别有用:存根(stub)和模拟(mock)。存根返回数据。模拟验证方法是否被调用。我们已经学会了如何使用 Mockito 库为我们创建存根和模拟。

我们已经使用 AssertJ 验证了 SUT 在各种测试双倍条件下的行为是否正确。我们已经学会了如何测试抛出异常的错误条件。

这些技术扩展了我们的测试工具集。

在下一章中,我们将介绍一个非常有用的系统设计技术,它允许我们在 FIRST 单元测试中获取大部分代码,同时避免测试外部系统协作时无法控制的问题。

问题和答案

  1. 存根和模拟这两个术语可以互换使用吗?

是的,尽管它们有不同的含义。在正常对话中,我们倾向于为了流畅性而牺牲精确性,这是可以接受的。了解每种测试双倍的不同用途很重要。在说话时,通常最好是在一群人知道意思的情况下不要过于拘泥于细节。只要我们意识到测试双倍是正确的通用术语,并且具体的双倍类型有不同的角色,一切都会顺利。

  1. “测试模拟”这个问题是什么?

这发生在 SUT(系统单元)中没有实际逻辑,但我们仍然尝试编写单元测试的情况下。我们将测试双倍连接到 SUT 并编写测试。我们将发现,断言只检查测试双倍返回了正确的数据。这表明我们测试的水平不正确。这种错误可能是由设置不明智的代码覆盖率目标或强制执行一个同样不明智的按方法测试规则所驱动的。这个测试没有增加任何价值,应该被移除。

  1. 测试双倍可以在任何地方使用吗?

不。这只有在您使用依赖倒置原则设计代码,以便测试双倍可以替换生产对象的情况下才有效。使用 TDD 确实迫使我们早期考虑这类设计问题。

如果无法在需要的地方注入测试替身,那么后续编写测试将变得更加困难。在这一点上,遗留代码尤其困难,我建议阅读迈克尔·费瑟斯的书籍《与遗留代码有效工作》,以获取帮助添加测试到缺乏必要测试访问点的代码的技术。(参见进一步阅读列表。)

进一步阅读

  • site.mockito.org/

Mockito 库主页

  • 《与遗留代码有效工作》,迈克尔·C·费瑟斯 ISBN 978-0131177055

这本书解释了如何处理没有为测试替身提供依赖倒置访问点的遗留代码。它展示了一系列技术,以安全地重构遗留代码,从而可以引入测试替身。

第九章:六边形架构——解耦外部系统

我们已经学习了如何使用 arrange、act 和 assert 模板编写测试。我们还了解了一些称为 SOLID 原则的软件设计原则,这些原则帮助我们将软件分解成更小的组件。最后,我们学习了如何使用测试替身来代替协作组件,使 FIRST 单元测试更容易编写。在本章中,我们将结合所有这些技术,形成一种称为六边形架构的强大设计方法。

使用这种方法,我们将从将更多应用程序逻辑置于单元测试中并减少所需的集成和端到端测试数量中受益。我们将构建对应用程序外部变化的自然弹性。例如,更改数据库供应商等开发任务将简化,因为我们的代码需要更改的地方更少。我们还将能够对更大的单元进行单元测试,将其他方法中需要端到端测试的一些测试纳入单元测试。

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

  • 为什么外部系统难以处理

  • 依赖反转来拯救

  • 抽象化外部系统

  • 编写领域代码

  • 用测试替身替换外部系统

  • 单元测试更大的单元

  • Wordz – 抽象化数据库

技术要求

本章的代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter09找到。

为什么外部系统难以处理

在本节中,我们将回顾推动六边形架构方法背后的驱动力——与外部系统工作的困难。对外部系统的依赖会导致开发中出现问题。解决方案导致了一种很好的设计方法。

让我们看看处理外部系统的一种简单方法。我们用户的任务是从一个数据库中拉取本月的销售报告。我们将编写一段代码来完成这项任务。软件设计如下:

图 9.1 – 一段代码完成所有工作

图 9.1 – 一段代码完成所有工作

在这个设计中,我们以通常的方式将销售数据存储在数据库中。我们编写一些代码代表用户拉取报告。这是一段完成整个工作的单一代码步骤。它将连接到数据库,发送查询,接收结果,进行一些处理,并将结果格式化,以便用户阅读。

优点是,我们知道这种编码风格是有效的。它将实现其向用户提供销售报告的目标。缺点是,代码结合了三种不同的职责——访问数据库、执行逻辑和格式化报告。它可能会将数据库的 SQL 语句与html5标签混合,以生成格式化的报告。正如我们在前一章中看到的,这可能会使一个区域的未来代码更改波及到其他区域。理想情况下,这种情况不应该发生。但真正的挑战是为这段代码编写测试。我们需要解析和理解我们发送给用户报告的任何格式。我们还需要直接与该数据库交互。

在接下来的子节中,我们将回顾一些外部系统对测试提出的更广泛挑战。这包括环境问题、意外事务、不确定数据、操作系统调用和第三方库。

环境问题带来麻烦

我们软件运行的软件环境常常带来挑战。假设我们的代码从数据库中读取数据。即使代码是正确的,由于我们无法控制的环境问题,它可能无法读取这些数据。以下是一些可能的问题:

  • 网络连接中断:许多原因可能导致这种情况。本地,可能是由于网络电缆被错误地拔出。也许数据库托管在互联网上的某个地方,我们的 ISP 已经断开了连接。

  • 电力故障:数据库服务器或本地网络交换机的电力故障足以使数据库无法访问。

  • 设备限制:可能是数据库服务器本身已经用完了磁盘空间,无法运行。也许我们编写的确切查询以某种方式触发了数据库,导致完成时间过长,可能是由于缺少索引。

无论原因是什么,如果我们的代码无法访问数据库中的数据,它将无法工作。由于这是一个可能性,为我们的报告生成代码编写测试变得更加困难。

即使我们的代码可以访问数据库中的数据,在测试中与之交互也并不容易。假设我们编写了一个测试,验证我们能否通过读取用户名来正确读取生产数据库,那么我们期望读取哪个用户名呢?我们不知道,因为测试并不控制要添加哪些数据。可用的用户名将是真实用户添加的任何名称。我们可以让测试向数据库添加一个已知的测试用户名——但这样,我们就创建了一个真实用户可以与之交互的虚假用户。这根本不是我们想要的。

数据库存储数据,这给我们的测试带来了更多问题。假设我们针对测试数据库编写了一个测试,该测试首先写入一个测试用户名。如果我们之前已经运行了这个测试,测试用户名将已经存储在数据库中。通常,数据库会报告重复项错误,测试将失败。

需要清理针对数据库的测试。测试完成后必须删除存储的所有测试数据。如果我们尝试在测试成功后删除数据,如果测试失败,删除代码可能永远不会运行。我们可以通过在测试运行之前始终删除数据来避免这种情况。这样的测试将运行缓慢。

测试意外触发真实交易

当我们的代码仅限于访问生产系统时,那么每次我们使用该代码,生产系统中都会发生某些事情。支付处理器可能会发出费用。真实的银行账户可能会被扣除。警报可能会被激活,导致真正的疏散。在夏威夷的一个著名例子中,一个系统测试触发了一条真实的短信,说夏威夷正在遭受导弹袭击——但实际上并没有。这是非常严重的事情。

夏威夷误报导弹袭击警告

有关测试出错此例的详细信息,请参阅en.wikipedia.org/wiki/2018_Hawaii_false_missile_alert

意外触发真实交易可能导致公司遭受真实损失。它们可能导致业务的三 R(收入、声誉和保留)损失。这些都不好。我们的测试必须不会意外触发来自生产系统的真实后果。

我们应该期待什么数据?

在我们的销售报告示例中,编写测试的最大问题是,我们需要提前知道月度销售报告的正确答案。当我们连接到生产系统时,我们如何做到这一点?答案将是销售报告所说的那样。我们没有其他方式知道。

在我们能够测试销售报告代码是否正常工作之前,必须确保销售报告代码能够正确工作,这是一个大问题!这是一个我们无法打破的循环依赖。

操作系统调用和系统时间

有时,我们的代码可能需要调用操作系统来完成其工作。也许它需要不时地删除目录中的所有文件,或者它可能依赖于系统时间。一个例子就是一个日志文件清理工具,它每周一凌晨 2 点运行。该工具将删除/logfiles/目录中的所有文件。

测试此类工具将是困难的。我们必须等到周一凌晨 2 点,并验证所有日志文件是否已被删除。虽然我们可以使这可行,但这并不非常有效。如果能找到一个更好的方法,允许我们在任何我们想测试的时候进行测试,理想情况下不需要删除任何文件,那就太好了。

第三方服务的挑战

商业软件中的常见任务是接受客户的付款。为此,我们不可避免地会使用第三方支付处理器,例如 PayPal 或 Stripe,仅举两个例子。除了网络连接的挑战之外,第三方 API 还给我们带来了更多的挑战:

  • 服务中断时间:许多第三方 API 将有一段时间的预定维护,服务将暂时不可用。这对我们来说意味着“测试失败”。

  • API 更改:假设我们的代码使用 API 版本 1,而 API 版本 2 已上线。我们的代码仍然会使用版本 1 的调用,这可能在 API 的版本 2 上不再有效。现在,这被认为是一种相当糟糕的做法——被称为破坏已发布的接口——但它确实会发生。更糟糕的是,由于我们的一小块代码,版本 2 的更改可能会在我们的代码的各个地方引起变化。

  • 慢响应:如果我们的代码向外部服务发出 API 调用,总有可能响应会晚于我们的代码预期返回。我们的代码通常会以某种方式失败,并导致测试失败。

当我们将外部服务和单一的大型代码块混合时,会存在许多挑战,这会复杂化维护和测试。问题是我们可以做什么?下一节将探讨依赖倒置原则如何帮助我们遵循一种称为六边形架构的设计方法,这使得处理外部系统变得更加容易。

依赖倒置来拯救

在本节中,我们将回顾一种基于我们已知的 SOLID 原则的设计方法,即六边形架构。使用这种方法可以使我们在代码库的更大范围内更有效地使用 TDD。

我们在本书中之前学习了依赖倒置原则。我们了解到它有助于我们将想要测试的某些代码与其合作者的细节隔离开来。我们注意到这对于测试连接到我们无法控制的外部系统的事物是有用的。我们看到了单一职责原则如何引导我们将软件拆分成更小、更专注的任务。

将这些想法应用于我们之前的销售报告示例,我们会得到一个改进的设计,如下面的图表所示:

图 9.2 – 将 SOLID 应用于我们的销售报告

图 9.2 – 将 SOLID 应用于我们的销售报告

上述图表显示了我们将 SOLID 原则应用于拆分我们的销售报告代码的方式。我们使用了单一职责原则,将整体任务分解为三个单独的任务:

  • 格式化报告

  • 计算销售总额

  • 从数据库中读取销售数据

这已经使应用程序更容易使用。更重要的是,我们已经将计算销售总额的代码从用户和数据库中隔离出来。这个计算不再直接访问数据库。它通过另一段只负责这一点的代码进行。同样,计算结果也不再直接格式化并发送给用户。另一段代码负责这一点。

我们也可以在这里应用依赖倒置原则。通过倒置对格式化和数据库访问代码的依赖,我们的计算销售总额现在不再需要了解它们的任何细节。我们取得了重大突破:

  • 计算代码现在完全与数据库和格式化隔离

  • 我们可以替换任何可以访问任何数据库的代码片段

  • 我们可以替换任何可以格式化报告的代码片段

  • 我们可以用测试替身代替格式化和数据库访问代码

最大的好处是我们可以在不更改计算代码的情况下,替换任何可以访问任何数据库的代码片段。例如,我们可以从 Postgres SQL 数据库切换到 Mongo NoSQL 数据库,而不需要更改计算代码。我们可以使用数据库的测试替身,以便我们可以将计算代码作为一个首次单元测试来测试。这些优势非常显著,不仅在于 TDD 和测试方面,还在于我们代码的组织方式。考虑到这个单件销售报告解决方案,我们已经从纯代码编写过渡到软件工程。我们思考的不仅仅是让代码工作,而是让代码易于工作。接下来的几个小节将探讨我们如何将这种方法推广到六边形架构,我们将了解这种方法如何提供一种逻辑的代码组织,帮助我们更有效地应用 TDD。

将这种方法推广到六边形架构

这种单一责任原则和依赖倒置的组合似乎给我们带来了一些好处。我们能否将这种方法扩展到整个应用程序并得到相同的好处?我们能否找到一种方法,将所有应用程序逻辑和数据表示从外部影响的约束中分离出来?我们当然可以,这种设计的一般形式如下所示:

图 9.3 – 六边形架构

图 9.3 – 六边形架构

上述图表显示了当我们将依赖倒置和单一责任原则推广到整个应用程序时会发生什么。它被称为六边形架构,也称为端口和适配器,这是 Alastair Cockburn 最初描述这种方法时使用的术语。好处是它完全隔离了应用程序的核心逻辑与外部系统的细节。这有助于我们测试核心逻辑。它还为我们代码的精心设计提供了一个合理的模板。

六边形架构组件概述

为了提供我们核心应用程序逻辑的隔离,六边形架构将整个程序划分为四个空间:

  • 外部系统,包括网络浏览器、数据库和其他计算服务

  • 适配器实现了外部系统所需的特定 API

  • 端口是我们应用程序从外部系统需要的抽象

  • 领域模型包含我们的应用程序逻辑,不包含外部系统的细节

我们应用程序的核心是领域模型,周围是它从外部系统需要的支持。它间接使用但不由这些外部系统定义。让我们更详细地了解六边形架构中的每个组件,以了解每个组件负责什么和什么不负责。

外部系统连接到适配器

外部系统是我们代码库之外的所有事物。它们包括用户直接与之交互的事物,例如前面图中的网络浏览器和控制台应用程序。它们还包括数据存储,如 SQL 数据库和 NoSQL 数据库。其他常见的系统示例包括桌面图形用户界面、文件系统、下游 Web 服务 API 和硬件设备驱动程序。大多数应用程序都需要与这些系统交互。

在六边形架构中,我们应用程序代码的核心不知道任何关于如何与外部系统交互的细节。与外部系统通信的责任被赋予了一块被称为适配器的代码。

例如,以下图示展示了网络浏览器如何通过 REST 适配器连接到我们的代码:

图 9.4 – 浏览器连接到 REST 适配器

图 9.4 – 浏览器连接到 REST 适配器

在前面的图中,我们可以看到网络浏览器连接到一个 REST 适配器。这个适配器理解 HTTP 请求和响应,这是网络的根本。它还理解 JSON 数据格式,通常使用库将 JSON 数据转换为我们的代码的一些内部表示。这个适配器还将理解我们为应用程序的 REST API 设计的特定协议——我们作为 API 提出的精确的 HTTP 动词、响应、状态码和 JSON 编码的有效负载数据。

注意

适配器封装了我们系统与外部系统交互所需的所有知识——没有其他知识。这种知识由外部系统的规范定义。其中一些可能是由我们自己设计的。

适配器有单一的责任,即知道如何与外部系统交互。如果该外部系统更改其公共接口,只有我们的适配器需要更改。

适配器连接到端口

向领域模型迈进,适配器连接到端口。端口是领域模型的一部分。它们抽象掉了适配器对其外部系统复杂知识的细节。端口回答了一个稍微不同的问题:我们为什么需要这个外部系统?端口使用依赖倒置原则来隔离我们的领域代码,使其不知道任何关于适配器的细节。它们完全用我们的领域模型来编写:

图 9.5 – 适配器连接到端口

图 9.5 – 适配器连接到端口

之前描述的 REST 适配器封装了运行 REST API 的细节,使用了 HTTP 和 JSON 的知识。它连接到一个命令端口,为我们提供了从网络或其他地方传入命令的抽象。鉴于我们之前的销售报告示例,命令端口将包括一种无技术请求销售报告的方式。在代码中,它可能看起来像这样:

package com.sales.domain;
import java.time.LocalDate;
public interface Commands {
    SalesReport calculateForPeriod(LocalDate start,
                                   LocalDate end);
}

此代码片段具有以下特点:

  • 没有对 HttpServletRequest 或任何与 HTTP 相关的引用

  • 没有对 JSON 格式的引用

  • 对我们的领域模型(SalesReportjava.time.LocalDate)的引用

  • public 访问修饰符,因此可以从 REST 适配器调用

此接口是一个端口。它为我们提供了一个通用方法,可以从应用程序中获取销售报告。参照 图 9**.3,我们可以看到控制台适配器也连接到这个端口,为用户提供命令行界面访问我们的应用程序。原因是尽管用户可以使用不同类型的系统(如网页和命令行)访问我们的应用程序,但我们的应用程序在两种情况下都做同样的事情。它只支持一组命令,无论这些命令从哪里请求。获取 SalesReport 对象就是这样,无论你从哪种技术请求它。

注意

端口提供了对外部系统所需逻辑视图,而不限制如何从技术上满足这些需求。

端口是我们反转依赖的地方。端口代表领域模型需要那些外部系统的原因。如果适配器代表“如何”,端口则代表“为什么”。

端口连接到我们的领域模型

链接到领域模型的最终步骤。这是我们的应用程序逻辑所在之处。把它想象成解决我们应用程序所面临问题的纯逻辑。由于端口和适配器,领域逻辑不受外部系统细节的限制:

图 9.6 – 端口连接到领域模型

图 9.6 – 端口连接到领域模型

领域模型代表用户想要做的事情,在代码中。每个用户故事都由这里的代码描述。理想情况下,这一层的代码使用我们正在解决的问题的语言,而不是技术细节。当我们做得很好时,这段代码就变成了故事讲述——它用用户告诉我们的术语描述用户关心的动作。它使用他们的语言——用户的语言——而不是晦涩的计算机语言。

领域模型可以包含用任何范式编写的代码。它可能使用函数式编程FP)的思想。它甚至可能使用面向对象编程OOP)的思想。它可能是过程式的。它甚至可能使用我们通过声明性配置的现成库。我目前的风格是使用面向对象来构建程序的整体结构和组织,然后在对象方法内部使用函数式编程思想来实现它们。这对六边形架构或 TDD 来说都没有影响。只要使用端口和适配器的思想,任何适合你的编码风格都是可以的。

注意

领域模型包含描述用户问题如何被解决的代码。这是我们应用程序的必要逻辑,它创造了商业价值。

整个应用程序的中心是领域模型。它包含将用户的用例故事变为现实生活的逻辑。

金规则——领域从不直接连接到适配器

为了保留将领域模型从适配器和外部系统隔离的好处,我们遵循一条简单的规则:领域模型从不直接连接到任何适配器。这始终是通过端口来完成的。

当我们的代码遵循这种设计方法时,检查我们是否正确地分割了端口和适配器就变得简单直接。我们可以做出两个高级结构决策:

  • 领域模型位于domain包(及其子包)中

  • 适配器位于adapters包(及其子包)中

我们可以通过分析代码来检查domain包中是否没有任何从adapters包导入的语句。导入检查可以在代码审查或结对/群体编程中进行。像 SonarQube 这样的静态分析工具可以将导入检查自动化,作为构建管道的一部分。

六边形架构的金规则

领域模型从不直接连接到适配器层中的任何内容,这样我们的应用程序逻辑就不依赖于外部系统的细节。

适配器连接到端口,以便将连接到外部系统的代码隔离。

端口是领域模型的一部分,用于创建外部系统的抽象。

领域模型和适配器只依赖于端口。这是依赖倒置在起作用。

这些简单的规则使我们的设计保持一致,并保留了领域模型的隔离性。

为什么是六边形形状?

图中使用的六边形形状背后的思想是,每个面代表一个外部系统。在设计的图形表示中,通常表示多达六个外部系统就足够了。内六边形和外六边形代表领域模型和适配器层,图形化地显示了领域模型是如何成为我们应用程序的核心,以及它是如何通过端口和适配器层与外部系统隔离的。

六角架构背后的关键思想是端口和适配器技术。实际边数取决于有多少外部系统。这些数量并不重要。

在本节中,我们介绍了六角架构及其提供的优势,并提供了所有基本组成部分如何组合的一般概述。让我们转向下一节,具体看看我们需要做出哪些决策来抽象外部系统。

抽象外部系统

在本节中,我们将考虑在应用六角架构方法时需要做出的某些决策。我们将逐步处理外部系统,首先决定领域模型需要什么,然后制定合适的抽象来隐藏其技术细节。我们将考虑两个常见的外部系统:Web 请求和数据库访问。

决定我们的领域模型需要什么

我们设计开始的地方是领域模型。我们需要为领域模型设计一个合适的端口以与之交互。这个端口必须摆脱任何外部系统的细节,同时,它必须回答我们的应用程序为什么需要这个系统的问题。我们正在创建一个抽象。

思考抽象的一个好方法是想如果我们改变执行任务的方式,什么会保持不变。假设我们想在午餐时吃热汤。我们可能在炉灶上的平底锅里加热,或者可能在微波炉里加热。无论我们选择哪种方式,我们正在做的事情保持不变。我们在加热汤,这就是我们寻找的抽象。

我们在软件系统中很少加热汤,除非我们正在构建一个自动售汤机。但我们将使用几种常见的抽象。这是因为当构建典型的 Web 应用程序时,会使用一些常见的外部系统。第一个也是最明显的是与 Web 本身的连接。在大多数应用程序中,我们都会遇到某种类型的数据存储,通常是第三方数据库系统。对于许多应用程序,我们还会调用另一个 Web 服务。反过来,这个服务可能会调用我们公司内部的一群服务。另一个典型的 Web 服务调用是调用第三方 Web 服务提供商,例如,作为一个信用卡支付处理器的例子。

让我们看看如何抽象这些常见的外部系统。

抽象 Web 请求和响应

我们的应用程序将响应 HTTP 请求和响应。我们需要设计的端口代表领域模型中的请求和响应,去除 Web 技术。

我们的销售额报告示例可以作为两个简单的领域对象引入这些概念。这些请求可以通过一个RequestSalesReport类来表示:

package com.sales.domain;
import java.time.LocalDate;
public class RequestSalesReport {
    private final LocalDate start;
    private final LocalDate end;
    public RequestSalesReport(LocalDate start,
                              LocalDate end){
        this.start = start;
        this.end = end;
    }
    public SalesReport produce(SalesReporting reporting) {
        return reporting.reportForPeriod(start, end);
    }
}

在这里,我们可以看到我们请求领域模型的关键部分:

  • 我们请求的内容——即销售报告,体现在类名中

  • 那个请求的参数——即报告期的开始和结束日期

我们可以看到响应是如何表示的:

  • SalesReport 类将包含所需的基本信息

我们还可以看到什么没有包含:

  • 网络请求中使用的数据格式

  • HTTP 状态码,例如 200 OK

  • HTTPServletRequestHttpServletResponse 或等效的框架对象

这是一个纯粹的业务模型表示,用于在两个日期之间请求销售报告。没有任何迹象表明它是从网络上来的,这是一个非常有用的事实,因为我们可以从其他输入源请求它,例如桌面 GUI 或命令行。更好的是,我们可以在单元测试中非常容易地创建这些业务模型对象。

上述示例展示了面向对象、告知而非询问的方法。我们同样可以选择函数式编程(FP)方法。如果我们这样做,我们会将请求和响应表示为纯数据结构。Java 17 中添加的记录功能非常适合表示此类数据结构。重要的是,请求和响应应完全用业务模型术语编写——不应该包含任何网络技术。

抽象数据库

没有数据,大多数应用程序并不特别有用。没有数据存储,它们会变得相当健忘,忘记我们提供的数据。访问数据存储,如关系数据库和 NoSQL 数据库,是网络应用程序开发中的常见任务。

在六边形架构中,我们首先设计业务模型将与之交互的端口,再次在纯业务术语中。创建数据库抽象的方法是考虑需要存储什么数据,而不是如何存储数据。

数据库端口有两个组成部分:

  • 一个接口来反转对数据库的依赖。

该接口通常被称为存储库。它也被称作数据访问对象。无论名称如何,它的任务是隔离业务模型与数据库的任何部分及其访问技术。

  • 用业务模型术语表示数据本身的值对象。

存在的值对象用于在各个地方传输数据。两个包含相同数据值的值对象被认为是相等的。它们非常适合在数据库和我们的代码之间传输数据。

回到我们的销售报告示例,我们仓库的一个可能的设计如下:

package com.sales.domain;
public interface SalesRepository {
    List<Sale> allWithinDateRange(LocalDate start,
                                  LocalDate end);
}

在这里,我们有一个名为 allWithinDateRange() 的方法,允许我们获取特定日期范围内的一系列单独的销售交易。数据作为 java.util.List 的简单 Sale 值对象返回。这些是功能齐全的业务模型对象。它们可能包含执行某些关键应用逻辑的方法。它们可能只是基本的数据结构,可能使用 Java 17 的 record 结构。这种选择是我们决定在特定情况下良好设计的外观的一部分。

同样,我们可以看到什么没有包含:

  • 数据库连接字符串

  • JDBCJPA API 细节——标准的 Java 数据库连接库

  • SQL查询(或 NoSQL 查询)

  • 数据库模式和表名

  • 数据库存储过程细节

我们的数据存储库设计侧重于我们的领域模型需要数据库提供的内容,但并不限制其提供方式。因此,在设计我们的存储库时,我们必须做出一些有趣的决策,关于我们在数据库中投入多少工作量,以及我们在领域模型本身中做多少工作。这包括决定我们是否会在数据库适配器中编写复杂的查询,或者是否编写更简单的查询并在领域模型中执行额外的工作。同样,我们是否会使用数据库中的存储过程?

无论我们在这类决策中做出何种权衡,数据库适配器都是所有这些决策的所在地。适配器是我们看到数据库连接字符串、查询字符串、表名等地方。适配器封装了我们的数据模式设计细节和数据库技术。

对网络服务的调用抽象

调用其他网络服务是一个常见的发展任务。这包括对支付处理器和地址查找服务的调用。有时,这些是第三方外部服务,有时它们存在于我们的网络服务舰队中。无论如何,它们通常需要从我们的应用程序中发出一些 HTTP 调用。

这些调用的抽象过程与抽象数据库类似。我们的端口由一个接口组成,它反转了对我们正在调用的网络服务的依赖,以及一些用于传输数据的价值对象。

例如,抽象对映射 API 的调用,如 Google Maps,可能看起来像这样:

package com.sales.domain;
public interface MappingService {
    void addReview(GeographicLocation location,
                   Review review);
}

我们有一个接口代表整个MappingService。我们添加了一个方法,可以在我们最终使用的任何服务提供商上添加特定位置的评论。我们使用GeographicLocation来表示一个地方,按照我们的定义。它可能包含一对纬度和经度,或者可能基于邮政编码。这是另一个设计决策。同样,我们没有看到底层地图服务或其 API 细节的任何迹象。这段代码位于适配器中,它会连接到真正的外部映射网络服务。

这种抽象使我们能够使用测试替身来处理该外部服务,并且能够在未来更改服务提供商。你永远不知道一个外部服务何时会关闭或变得过于昂贵而无法使用。使用六边形架构来保持我们的选择是件好事。

本节提出了一些在六边形架构中处理外部系统最常见任务的思路。在下一节中,我们将讨论在领域模型中编写代码的一般方法。

编写领域代码

在本节中,我们将探讨我们在编写领域模型代码时需要考虑的一些事情。我们将涵盖在领域模型中我们应该使用和不应该使用哪些类型的库,我们如何处理应用程序配置和初始化,我们还将思考流行框架的影响。

决定我们的领域模型中应该包含什么

我们的领域模型是应用程序的核心,六边形架构将其置于最前沿和中心位置。一个好的领域模型是用用户问题域的语言编写的;这就是名字的由来。我们应该看到用户会认识到的程序元素的名字。我们应该认识到我们正在解决的问题,而不仅仅是解决它的机制。理想情况下,我们将看到用户故事中的术语在我们的领域模型中被使用。

应用六边形架构,我们选择我们的领域模型独立于那些对解决问题不是本质的东西。这就是为什么外部系统是隔离的。我们最初可能会认为创建销售报告意味着我们必须读取文件,我们必须创建一个 HTML 文档。但这并不是问题的核心。我们只需要从某个地方获取销售数据,进行一些计算以获取报告的总数,然后以某种方式格式化它。某个地方和某种方式可以改变,而不会影响我们解决方案的本质。

考虑到这个限制,我们可以采取任何标准的分析和设计方法。我们可以自由选择对象或像通常那样将它们分解成函数。我们只需要保留问题本质和实现细节之间的区别。

在这些决策中,我们需要运用判断力。在我们的销售报告示例中,销售数据的来源无关紧要。作为一个反例,假设我们正在为我们的 Java 程序文件创建一个代码检查器——在领域模型中直接表示文件的概念是相当合理的。这个问题域完全是关于处理 Java 文件,所以我们应该明确这一点。我们仍然可以将文件领域模型与读取和写入它的特定于操作系统的细节解耦,但这个概念将包含在领域模型中。

在领域模型中使用库和框架

领域模型可以使用任何预先编写的库或框架来帮助完成其工作。像 Apache Commons 或 Java 标准运行时库这样的流行库通常在这里不会引起问题。然而,我们需要意识到那些将我们绑定到外部系统和我们适配器层的框架。我们需要反转对这些框架的依赖,让它们只是适配器层的一个实现细节。

一个例子可能是 Spring Boot 的@RestController注解。乍一看,它看起来像是纯领域代码,但它将类紧密地绑定到特定于 Web 适配器的生成代码。

决定编程方法

领域模型可以使用任何编程范式来编写。这种灵活性意味着我们需要决定使用哪种方法。这从来不是一个纯粹的技术决策,就像软件中的许多事情一样。我们应该考虑以下因素:

  • 现有团队技能和偏好:团队最擅长哪种范式?如果有机会,他们希望使用哪种范式?

  • 现有库、框架和代码库:如果我们将要使用预写的代码——让我们面对现实,我们几乎肯定会这样做——那么哪种范式最适合那代码?

  • 风格指南和其他代码规范:我们是否在与现有的风格指南或范式合作?如果我们为我们的工作付费——或者我们正在为现有的开源项目做出贡献——我们需要采用为我们设定的范式。

好消息是,无论我们选择哪种范式,我们都能够成功地编写领域模型。虽然代码可能看起来不同,但可以使用任何范式编写等效的功能。

用测试替身替换外部系统

在本节中,我们将讨论六边形架构为 TDD 带来的最大优势之一:高可测试性。它还带来了一些工作流程优势。

用测试替身替换适配器

六边形架构为 TDD 带来的关键优势是,替换所有适配器为测试替身非常容易,这使我们能够使用 FIRST 单元测试来测试整个领域模型。我们可以测试整个应用程序核心逻辑,而无需测试环境、测试数据库或 Postman 或 curl 等 HTTP 工具——只需快速、可重复的单元测试。我们的测试设置如下所示:

图 9.7 – 测试领域模型

图 9.7 – 测试领域模型

我们可以看到所有适配器都已替换为测试替身,完全使我们摆脱了外部系统的环境。单元测试现在可以覆盖整个领域模型,减少了对集成测试的需求。

通过这样做,我们获得几个好处:

  • 我们可以轻松地首先编写 TDD 测试:编写一个完全存在于内存中且不依赖于测试环境的简单测试替身没有任何摩擦。

  • 我们获得了 FIRST 单元测试的好处:我们的测试确实非常快,并且是可重复的。通常,测试整个领域模型只需要几秒钟,而不是几个小时。测试将可重复地通过或失败,这意味着我们永远不会怀疑构建失败是否是由于不可靠的集成测试失败。

  • 它释放了我们的团队:我们可以构建系统核心逻辑的有用工作,而无需等待测试环境的设计和构建。

第八章“测试替身 – 模拟和存根”中概述了创建测试替身的技术。在实现这些替身方面不需要任何新的要求。

能够测试整个领域模型的一个后果是,我们可以将 TDD 和 FIRST 单元测试应用于更大的程序单元。下一节将讨论这对我们意味着什么。

单元测试更大的单元

上一节介绍了围绕我们的领域模型为每个端口使用测试替身的想法。这为我们提供了在本节中讨论的一些有趣的机会。我们可以测试与用户故事一样大的单元。

我们熟悉单元测试作为测试小规模事物。有很大可能性你听说过有人说单元测试应该只应用于单一函数,或者每个类应该为每个方法有一个单元测试。我们已经看到那种方式并不是使用单元测试的最佳方式。像那样的测试会错过一些优势。我们更倾向于将测试视为覆盖行为。

使用六边形架构进行设计和测试行为而不是实现细节的联合方法导致了一个有趣的结构分层。而不是像在三层架构中那样有传统层,我们有越来越高层次行为的圆圈。在我们的领域模型内部,我们将找到那些小规模测试。但随着我们向外移动,向适配器层移动,我们将找到更大的行为单元。

单元测试整个用户故事

领域模型中的端口构成了领域模型的自然高级边界。如果我们回顾本章学到的内容,我们会看到这个边界由以下内容组成:

  • 用户请求的本质

  • 我们的应用程序响应的本质

  • 数据需要存储和访问的本质

  • 所有使用无技术代码

这一层是我们应用程序本质的体现,不受其如何做的细节影响。这不仅仅是原始用户故事本身。这个领域模型最显著的事情是我们可以针对它编写 FIRST 单元测试。我们有所有需要用简单的测试替身替换难以测试的外部系统的工具。我们可以编写覆盖整个用户故事的单元测试,以确认我们的核心逻辑是正确的。

更快、更可靠的测试

传统上,测试用户故事涉及在测试环境中进行的较慢的集成测试。六边形架构使得单元测试可以替代一些这些集成测试,加快我们的构建速度,并提高测试的可重复性。

我们现在可以在领域模型上以三个粒度进行测试驱动:

  • 与单一方法或函数相反

  • 与类的公共行为及其任何协作者相反

  • 与整个用户故事的核心逻辑相反

这是六边形架构的一个大好处。外部服务的隔离效果是将用户故事的基本逻辑推入领域模型,在那里它与端口交互。正如我们所看到的,那些端口——按照设计——非常容易编写测试替身。重申 FIRST 单元测试的关键好处是值得的:

  • 它们非常快,所以测试我们的用户故事将会非常快

  • 它们高度可重复,所以我们可以信任测试的通过和失败

随着我们通过单元测试覆盖广泛的功能区域,我们模糊了集成测试和单元测试之间的界限。通过使测试更容易,我们减少了开发者测试更多用户故事时的摩擦。使用更多的单元测试可以提高构建时间,因为测试运行得快,并且给出可靠的通过/失败结果。需要的集成测试更少,这是好事,因为它们运行得更慢,更容易出现错误结果。

在下一节中,我们将把所学知识应用到 Wordz 应用程序中。我们将编写一个端口,抽象出为用户猜测单词的检索细节。

Wordz – 抽象数据库

在本节中,我们将把所学知识应用到 Wordz 应用程序中,并创建一个适合检索单词以展示给用户的端口。我们将在第十四章,“驱动数据库层”中编写适配器和集成测试。

设计存储库界面

设计我们的端口的第一项工作是决定它应该做什么。对于一个数据库端口,我们需要考虑我们希望领域模型负责的部分和我们将推送到数据库的部分之间的分割。我们用于数据库的端口通常被称为存储库接口。

应该有三个广泛的原则来指导我们:

  • 思考领域模型需要什么——我们为什么需要这些数据?它将用于什么?

  • 不要简单地重复假设的数据库实现——在这个阶段,不要从表和外键的角度思考。那是在我们决定如何实现存储时的事情。有时,数据库性能的考虑意味着我们必须重新审视我们在这里创建的抽象。如果我们这样做能让数据库运行得更好,我们可能会牺牲一些数据库实现细节。我们应该尽可能晚地做出这样的决定。

  • 考虑我们应该何时更多地利用数据库引擎。也许我们打算在数据库引擎中使用复杂的存储过程。在存储库界面中反映这种行为分割。这可能表明存储库界面需要更高层次的抽象。

对于我们的运行示例应用程序,让我们考虑为用户随机检索一个单词的任务。我们应该如何在领域和数据库之间分配工作?有两种广泛的选择:

  • 让数据库随机选择一个单词

  • 让领域模型生成一个随机数,并让数据库提供编号的单词

通常,让数据库做更多的工作会导致更快的数据处理;数据库代码更接近数据,并且不会将数据拖过网络连接到我们的领域模型。但如何说服数据库随机选择一些内容呢?我们知道对于关系型数据库,我们可以发出一个查询,该查询将返回无保证顺序的结果。这有点像随机。但这是否足够随机?在所有可能的实现中?似乎不太可能。

替代方案是让领域模型代码通过生成一个随机数来决定选择哪个单词。然后,我们可以发出一个查询来获取与该数字关联的单词。这也暗示了每个单词都有一个与之关联的数字——这是我们可以在设计数据库模式时提供的。

这种方法意味着我们需要领域模型从与单词关联的所有数字中选择一个随机数。这意味着领域模型需要知道要选择的全部数字集合。我们可以在另一个设计决策中这样做。用于标识单词的数字将从 1 开始,每个单词增加 1。我们可以提供一个方法来获取这些数字的上限。然后,我们就准备好定义该存储库接口——并附带一个测试。

测试类以我们需要声明的包和库导入开始:

package com.wordz.domain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.when;

我们通过junit-jupiter库提供的注解启用 Mockito 集成。我们在类级别添加该注解:

@ExtendWith(MockitoExtension.class)
public class WordSelectionTest {

这将确保在每个测试运行时初始化 Mockito。测试的下一部分定义了一些整数常量以提高可读性:

    private static final int HIGHEST_WORD_NUMBER = 3;
    private static final int WORD_NUMBER_SHINE = 2;

我们需要两个测试替身,我们希望 Mockito 为我们生成。我们需要一个单词存储库的替身和一个随机数生成器的替身。我们必须为这些替身添加字段。我们将这些字段标记为 Mockito 的@Mock注解,以便 Mockito 为我们生成替身:

    @Mock
    private WordRepository repository;
    @Mock
    private RandomNumbers random;

当我们使用@Mock注解时,Mockito 在模拟或替身之间没有区别。它只是创建一个可以配置为用作模拟或替身的测试替身。这是在测试代码中稍后完成的。

我们将测试方法命名为selectsWordAtRandom()。我们希望驱动出一个名为WordSelection的类,并使其负责从WordRepository中随机选择一个单词:

    @Test
    void selectsWordAtRandom() {
        when(repository.highestWordNumber())
            .thenReturn(HIGHEST_WORD_NUMBER);
        when(repository.fetchWordByNumber(WORD_NUMBER_SHINE))
            .thenReturn("SHINE");
        when(random.next(HIGHEST_WORD_NUMBER))
            .thenReturn(WORD_NUMBER_SHINE);
        var selector = new WordSelection(repository,
                                         random);
        String actual = selector.chooseRandomWord();
        assertThat(actual).isEqualTo("SHINE");
    }
}

前面的测试是以常规方式编写的,通过添加行来捕获每个设计决策:

  • WordSelection 类封装了算法,该算法用于选择一个单词进行猜测

  • WordSelection构造函数接受两个依赖项:

    • WordRepository是存储单词的端口

    • RandomNumbers 是随机数生成的端口

  • chooseRandomWord()方法将返回一个随机选择的单词作为字符串

  • arrange部分被移动到beforeEachTest()方法中:

    @BeforeEach
    
    void beforeEachTest() {
    
        when(repository.highestWordNumber())
    
                      .thenReturn(HIGHEST_WORD_NUMBER);
    
        when(repository.fetchWordByNumber(WORD_NUMBER_SHINE))
    
                      .thenReturn("SHINE");
    
    }
    

这将在每个测试的开始时设置我们的WordRepository的测试数据。编号为 2 的单词被定义为SHINE,因此我们可以在断言中检查这一点。

  • 从测试代码中产生了以下两个接口方法的定义:

    package com.wordz.domain;
    
    public interface WordRepository {
    
        String fetchWordByNumber(int number);
    
        int highestWordNumber();
    
    }
    

WordRepository接口定义了我们的应用程序对数据库的看法。根据我们的当前需求,我们只需要两个设施:

  • 一个fetchWordByNumber()方法来获取一个单词,给定其标识编号

  • 一个highestWordNumber()方法来说明最高的单词编号将是什么

测试还驱使出我们随机数生成器所需的接口:

package com.wordz.domain;
public interface RandomNumbers {
    int next(int upperBoundInclusive);
}

单一的next()方法返回一个int,范围在 1 到upperBoundInclusive数字之间。

在定义了测试和端口接口之后,我们可以编写领域模型代码:

package com.wordz.domain;
public class WordSelection {
    private final WordRepository repository;
    private final RandomNumbers random;
    public WordSelection(WordRepository repository,
                         RandomNumbers random) {
        this.repository = repository;
        this.random = random;
    }
    public String chooseRandomWord() {
        int wordNumber =
           random.next(repository.highestWordNumber());
        return repository.fetchWordByNumber(wordNumber);
    }
}

注意到这段代码没有从com.wordz.domain包外部导入任何内容。它是纯应用逻辑,仅依赖于端口接口来访问存储的单词和随机数。有了这个,我们的WordSelection领域模型的生成代码就完成了。

设计数据库和随机数适配器

下一个任务是实现RandomNumbers端口和数据库访问代码,该代码实现了我们的WordRepository接口。概述来说,我们将选择一个数据库产品,研究如何连接到它并运行数据库查询,然后使用集成测试驱动该代码。我们将把这些任务推迟到本书的第三部分,即第十三章驱动领域层和第十四章驱动数据库层

摘要

在本章中,我们学习了如何将 SOLID 原则应用于完全解耦外部系统,从而产生一个被称为六边形架构的应用程序架构。我们看到了如何使用测试替身代替外部系统,使我们的测试更容易编写,并具有可重复的结果。这反过来又允许我们使用 FIRST 单元测试来测试整个用户故事。作为额外的好处,我们使自己免受那些外部系统未来变化的影响,限制了支持新技术所需的重写工作。我们看到了六边形架构与依赖注入相结合如何使我们能够支持多种不同的外部系统选择,并在运行时通过配置选择我们想要的那个。

下一章将探讨适用于六边形架构应用程序不同部分的自动化测试的不同风格。这种方法总结为测试金字塔,我们将在那里了解更多关于它的内容。

问题与答案

查看以下关于本章内容的问题和答案:

  1. 我们能否后来添加六边形架构?

并非总是如此。我们可以重构它。挑战可能在于过多的代码直接依赖于外部系统的细节。如果这是起点,这次重构将会具有挑战性。将有很多重写工作要做。这意味着在开始工作之前,我们需要进行一定程度的前期设计和架构讨论。

  1. 六边形架构是否特定于面向对象编程(OOP)?

不,这是一种在我们代码中组织依赖关系的方式。它可以应用于面向对象编程(OOP)、函数式编程(FP)、过程式编程或其他任何东西——只要这些依赖关系得到正确管理。

  1. 我们在什么情况下不应该使用六边形架构?

当我们的领域模型中没有实际逻辑时。这对于通常作为数据库表前端的前端非常小的 CRUD 微服务来说很常见。没有逻辑可以隔离,放入所有这些代码没有任何好处。我们不妨只做集成测试的 TDD,并接受我们无法使用 FIRST 单元测试。

  1. 我们只能为外部系统有一个端口吗?

不。如果我们有更多的端口,通常会更好。假设我们有一个连接到我们的应用程序的单个 PostgreSQL 数据库,其中包含有关用户、销售和产品库存的数据。我们可以简单地有一个单一的存储库接口,其中包含处理这三个数据集的方法。但将这个接口拆分(遵循 ISP)并拥有 UserRepositorySalesRepositoryInventoryRepository 会更好。端口提供了我们的领域模型希望从外部系统获得视图。端口不是硬件的一对一映射。

进一步阅读

要了解更多关于本章涵盖的主题,请查看以下资源:

以端口和适配器为术语对六边形架构的原始描述。

感谢术语 FIRST 的原始发明者 Tim Ottinger 和 Brett Schuchert。

指导如何在生产系统上测试代码,而不会意外触发不希望的结果。

第十章:FIRST 测试和测试金字塔

到目前为止,在这本书中,我们已经看到了编写快速运行并给出可重复结果的单元测试的价值。被称为 FIRST 测试,这些测试为我们提供了关于设计的快速反馈。它们是单元测试的黄金标准。我们还看到了如何通过六边形架构帮助我们以最大程度地覆盖 FIRST 单元测试的方式来设计我们的代码。但我们也将自己限制在仅测试我们的领域模型——我们应用程序逻辑的核心。我们简单地没有测试覆盖领域模型连接到外部世界后的行为。

在本章中,我们将涵盖我们需要的所有其他类型的测试。我们将介绍测试金字塔,这是一种思考所需不同类型测试及其数量的方法。我们将讨论每种测试覆盖的内容以及有用的技术和工具来帮助。我们还将通过介绍 CI/CD 管道和测试环境,概述它们在将代码组件组合成最终用户系统中的关键作用。

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

  • 测试金字塔

  • 单元测试 – FIRST 测试

  • 集成测试

  • 端到端和用户验收测试

  • CI/CD 管道和测试环境

  • Wordz – 数据库的集成测试

技术要求

本章的代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter10找到。

要运行此代码,我们需要在本地安装开源的 Postgres 数据库。

要安装 Postgres,请执行以下操作:

  1. 在您的浏览器中转到www.postgresql.org/download/

  2. 点击您操作系统的正确安装程序:

图 10.1 – Postgres 安装程序选择

图 10.1 – Postgres 安装程序选择

  1. 按照您操作系统的说明进行操作。

测试金字塔

通过使用测试金字塔来思考不同类型的测试是一种非常有用的方法。它是我们代码周围不同类型测试及其相对数量的简单图形表示。本节介绍了测试金字塔背后的关键思想。

测试金字塔的图形形式如下所示:

图 10.2 – 测试金字塔

图 10.2 – 测试金字塔

从前面的图形中我们可以看到,测试被分为四层。我们在底部有单元测试。集成测试建立在那些之上。金字塔由顶部的端到端和用户验收测试完成。图形显示我们的系统中的单元测试数量最多,集成测试较少,验收测试最少。

这本书中的一些测试类型是新的。让我们定义它们是什么:

  • 单元测试

这些是熟悉的。它们是我们迄今为止一直在使用的 FIRST 测试。这些测试的一个定义特征是它们不需要任何外部系统的存在,例如数据库或支付处理器。

  • 集成测试

这些测试验证软件组件是否正确地与外部系统(如数据库)集成。这些测试速度较慢,并且关键依赖于外部环境是否可用以及是否正确设置以供我们的测试使用。

  • 端到端测试

这些是最广泛的测试。端到端测试代表的是非常接近最终用户体验的东西。这个测试是对系统的所有真实组件进行的,可能在测试环境中使用测试数据,使用与真实用户相同的命令。

  • 用户 验收测试

这是在用户会使用它的方式测试真实系统的地方。在这里,我们可以确认最终系统符合用户给出的要求,适合使用。

起初并不明显,为什么减少任何类型的测试数量会是一个优势。毕竟,到目前为止,这本书中所有内容都积极赞扬了测试的价值。为什么我们不简单地进行所有测试呢?答案是一个实用主义的观点:并非所有测试都是平等的。它们并不都为我们作为开发者提供相同的价值。

这个金字塔形状的原因是为了反映每个测试层的实际价值。作为 FIRST 测试编写的单元测试是快速可重复的。如果我们能够仅用这些单元测试构建一个系统,我们当然会的。但是单元测试并不测试我们代码库的每个部分。具体来说,它们并不测试我们的代码与外部世界的连接,也不以用户使用它的方式测试我们的应用程序。随着我们通过测试层级的提升,我们逐渐从测试软件的内部组件转向测试它与外部系统以及最终我们的应用程序用户的交互。

测试金字塔是关于平衡的。它的目标是创建能够实现以下目标的测试层:

  • 尽可能快地运行

  • 尽可能覆盖尽可能多的代码

  • 尽可能预防尽可能多的缺陷

  • 最小化测试工作的重复

在接下来的章节中,我们将查看测试金字塔每一层所涉及的测试的分解。我们将考虑每种测试的优缺点,使我们能够理解测试金字塔引导我们走向的方向。

单元测试 – FIRST 测试

在本节中,我们将探讨测试金字塔的基础,它由单元测试组成。我们将研究为什么这一层对成功至关重要。

到现在为止,我们对 FIRST 单元测试非常熟悉。前几章已经详细介绍了这些内容。它们是单元测试的黄金标准。它们运行速度快。它们可重复且可靠。它们相互独立运行,因此我们可以选择运行一个或多个,并且可以按任何顺序运行。FIRST 测试是 TDD 的强大动力,使我们能够在编码时拥有快速的反馈循环。理想情况下,所有代码都应包含在这个反馈循环中。它提供了一种快速、高效的工作方式。在每一步,我们都可以执行代码并证明它按预期工作。作为有益的副产品,通过编写测试来锻炼我们代码中每个可能的可取行为,我们将最终锻炼到每个可能的代码路径。当我们这样工作时,我们将获得 100%的有意义测试覆盖率。

由于它们的优点,单元测试构成了我们测试策略的基础。它们在测试金字塔中代表基础。

单元测试具有优点和局限性,如下表总结:

优点 局限性
这些是运行最快的测试,为我们提供了代码的最快反馈循环。 这些测试的范围较小,因此所有单元测试通过并不能保证整个系统运行正确。
稳定且可重复,不依赖于我们无法控制的事物。 可能会与实现细节过于紧密地绑定,使得未来的添加和重构变得困难。
可以提供非常详细的特定逻辑覆盖。准确定位缺陷。 对于测试与外部系统的交互没有帮助。

表 10.1 – 单元测试的优点和缺点

在任何系统中,我们都期望在单元级别拥有最多的测试。测试金字塔以图形方式表示这一点。

在现实世界中,仅使用单元测试无法实现全面覆盖,但我们可以改善我们的情况。通过将六边形架构应用于我们的应用程序,我们可以使大部分代码处于单元测试之下。我们快速运行的单元测试可以覆盖大量内容,并为我们应用程序的逻辑提供大量信心。我们可以知道,如果外部系统按我们预期的方式表现,我们的领域层代码将能够正确处理我们考虑过的每个用例。

使用单元测试单独进行测试时的测试位置如图所示:

图 10.3 – 单元测试覆盖领域模型

图 10.3 – 单元测试覆盖领域模型

单元测试只测试我们的领域模型组件。它们不测试外部系统,也不使用外部系统。它们依赖于测试替身来模拟我们的外部系统。这给我们带来了开发周期速度上的优势,但缺点是那些外部系统的连接仍然未经过测试。如果我们有一段经过单元测试的代码访问存储库接口,我们知道它的逻辑与存根存储库兼容。它的内部逻辑甚至有 100%的测试覆盖率,这是有效的。但我们还不知道它是否与真实存储库兼容。

适配器层代码负责这些连接,并且它不在单元测试级别进行测试。为了测试这一层,我们需要一种不同的测试方法。我们需要测试当我们的领域层代码与实际外部系统集成时会发生什么。

下一节将探讨如何使用一种称为集成测试的测试方法来测试这些外部系统适配器。

集成测试

在本节中,我们将探讨测试金字塔中的下一层:集成测试。我们将了解其重要性,回顾有用的工具,并理解集成测试在整个方案中的作用。

集成测试存在是为了测试我们的代码能否成功与外部系统集成。我们的核心应用程序逻辑通过单元测试进行测试,而单元测试的设计原则是不与外部系统交互。这意味着我们需要在某个时候测试与那些外部系统的行为。

集成测试是测试金字塔的第二层。它们具有优点和局限性,如下表总结:

优点 局限性
测试软件组件在连接时是否正确交互 需要设置和维护测试环境
提供更接近实际使用软件系统的模拟 测试运行速度比单元测试慢
易受测试环境中的问题影响,例如数据错误或网络连接故障

表 10.2 – 集成测试的优点和缺点

集成测试的数量应该少于单元测试。理想情况下,要少得多。虽然单元测试通过使用测试替身避免了测试外部系统的许多问题,但集成测试现在必须面对这些挑战。本质上,它们更难设置。它们可以更不可重复。它们通常比单元测试运行得更慢,因为它们需要等待外部系统的响应。

为了让大家有一个概念,一个典型的系统可能有数千个单元测试和数百个验收测试。在这两者之间,我们有几个集成测试。许多集成测试指向设计机会。我们可以重构代码,使我们的集成测试被推到单元测试或提升为验收测试。

另一个减少集成测试数量的原因是由于不稳定的测试。不稳定的测试是对有时通过有时失败的测试的昵称。当它失败时,是由于与外部系统交互中存在某些问题,而不是我们正在测试的代码中的缺陷。这种失败被称为假阴性测试结果——一个可能会误导我们的结果。

不稳定的测试是一个麻烦,正是因为我们无法立即知道失败的根本原因。在没有深入错误日志的情况下,我们只知道测试失败了。这导致开发者学会忽略这些失败的测试,通常选择多次重新运行测试套件,直到不稳定的测试通过。这里的问题是,我们正在训练开发者对他们的测试失去信心。我们正在训练他们忽略测试失败。这并不是一个好的地方。

集成测试应该覆盖什么内容?

在我们目前的设计中,我们使用依赖倒置原则将外部系统从我们的领域代码中解耦。我们创建了一个接口,定义了我们如何使用该外部系统。将会有一些这个接口的实现,这就是我们的集成测试将要覆盖的内容。在六边形架构术语中,这被称为适配器

这个适配器应该只包含与外部系统交互所需的最小代码量,以满足我们的接口。它不应该包含任何应用程序逻辑。这些逻辑应该在领域层内部,并由单元测试覆盖。我们称之为瘦适配器,只做足够的工作以适应外部系统。这意味着我们的集成测试在范围上得到了很好的限制。

我们可以这样表示集成测试的范围:

图 10.4 – 集成测试覆盖适配器层

图 10.4 – 集成测试覆盖适配器层

集成测试只测试适配器层组件,那些直接与外部系统交互的代码片段,例如数据库和 Web 端点。集成测试将创建一个适配器实例,并安排它连接到外部服务的一个版本。这是很重要的。我们还没有连接到生产服务。直到集成测试通过,我们才确信我们的适配器代码工作正确。因此,我们还不希望访问真实的服务。我们还希望对这些服务有额外的控制级别。我们希望能够安全且容易地创建测试账户和伪造数据来与适配器一起使用。这意味着我们需要一组类似真实的服务和数据库来使用。这意味着它们必须存在于某个地方并运行。

测试环境是我们为集成测试所使用的那些外部系统的配置名称。这是一个运行 Web 服务和数据源的环境,专门用于测试。

测试环境使我们的代码能够连接到真实外部系统的测试版本。与单元测试级别相比,这更接近生产就绪状态。然而,使用测试环境涉及一些挑战。让我们来看看测试数据库和 Web 服务集成时的良好实践。

测试数据库适配器

测试数据库适配器的基本方法是在测试环境中设置数据库服务器,并让待测试的代码连接到它。集成测试作为其安排步骤的一部分,将预加载已知数据集到数据库中。然后,测试在行动步骤中运行与数据库交互的代码。断言步骤可以检查数据库,以查看是否发生了预期的数据库更改。

测试数据库的最大挑战是它记得数据。现在,这可能看起来有点明显,因为使用数据库的初衷就是如此。但它与测试的一个目标相冲突:拥有隔离、可重复的测试。例如,如果我们的测试为用户testuser1创建了一个新用户账户并将其存储在数据库中,我们再次运行该测试时就会遇到问题。它将无法创建testuser1,而是会收到用户已存在的错误。

有不同的方法可以克服这个问题,每种方法都有其权衡:

  • 在每个 测试用例前后从数据库中删除所有数据

这种方法保留了测试的隔离性,但速度较慢。我们必须在每次测试之前重新创建测试数据库模式。

  • 在适配器测试运行前后删除所有数据

我们较少地删除数据,允许多个相关测试针对同一数据库运行。由于存储的数据,这会失去测试隔离性,因为数据库将不会处于下一次测试开始时预期的状态。我们必须按特定顺序运行测试,并且它们都必须通过,以避免破坏下一次测试的数据库状态。这不是一个好的方法。

  • 使用 随机化数据

在我们的测试中,我们不是创建testuser1,而是随机化名称。因此,在某一轮测试中,我们可能会得到testuser-cfee-0a9b-931f。在下一轮测试中,随机选择的用户名将不同。存储在数据库中的状态不会与同一测试的另一轮运行冲突。这是另一种保持测试隔离性的方法。然而,这也意味着测试可能更难阅读。它需要定期清理测试数据库。

  • 回滚事务

我们可以在数据库事务中添加测试所需的数据。我们可以在测试结束时回滚事务。

  • 忽略 问题

有时,如果我们与只读数据库一起工作,我们可以添加永远不会被生产代码访问的测试数据,并将其保留在那里。如果这可行,这是一个吸引人的选项,不需要额外的工作。

例如 database-rider 这样的工具,可以从 database-rider.github.io/getting-started/ 获取,通过提供连接数据库和用测试数据初始化它们的库代码来协助测试。

测试网络服务

类似的方法用于测试与网络服务的集成。将网络服务的测试版本设置为在测试环境中运行。适配器代码被设置为连接到这个网络服务的测试版本,而不是真实版本。然后我们的集成测试可以检查适配器代码的行为。测试服务可能会有额外的网络 API,以便我们的测试中的断言进行检查。

再次强调,缺点是测试运行速度较慢,以及由于像网络拥塞这样微不足道的问题而导致测试不稳定的风险。

沙盒 API

有时候,托管我们自己的本地服务可能是不可能的,或者至少是不受欢迎的。第三方供应商通常不愿意发布测试版的服务供我们在测试环境中使用。相反,他们通常会提供一个 沙盒 API。这是第三方托管而不是我们自己的服务版本。它与他们的生产系统断开连接。这个沙盒允许我们创建测试账户和测试数据,安全地避免影响生产中的任何真实内容。它将像他们的生产版本一样对我们的请求做出响应,但不会采取任何行动,例如收取费用。把它们看作是真实服务的测试模拟器。

消费者驱动的合同测试

一种测试与网络服务交互的有用方法是称为 消费者驱动的合同测试。我们将我们的代码视为与外部服务有一个合同。我们同意在外部服务上调用某些 API 函数,并按所需的形式提供数据。我们需要外部服务以可预测的方式对我们做出响应,提供已知格式和易于理解的状态码。这形成了双方之间的 合同 – 我们的代码和外部服务 API。

消费者驱动的合同测试涉及两个组件,基于该合同,通常使用由工具生成的代码。这在上面的图中表示:

图 10.5 – 消费者驱动的合同测试

图 10.5 – 消费者驱动的合同测试

上述图示显示,我们已经将预期与外部服务的交互捕获为 API 合同。我们为该服务编写的适配器将实现该 API 合同。当使用消费者驱动的合同测试时,我们最终得到两个测试,分别测试该合同的两侧。如果我们认为一个服务是一个黑盒,那么我们有一个由黑盒提供的公共接口,以及一个实现,其细节隐藏在黑盒内部。合同测试是两个测试。一个测试确认外部接口与我们的代码兼容。另一个测试确认该接口的实现工作正常并给出预期的结果。

一个典型的合同测试将需要两段代码:

  • 外部服务的存根:生成外部服务的存根。如果我们正在调用支付处理器,这个存根在本地模拟支付处理器。这允许我们将其用作编写适配器代码时的测试替身。我们可以针对我们的适配器编写集成测试,配置它调用这个存根。这允许我们在不访问外部系统的情况下测试适配器代码逻辑。我们可以验证适配器是否向该外部服务发送正确的 API 调用,并正确处理预期的响应。

  • 对真实外部服务的一系列调用的回放:合同还允许我们对真实的外部服务进行测试——可能是在沙盒模式下。在这里,我们不是测试外部服务的功能——我们假设服务提供商已经完成了这项工作。相反,我们正在验证我们对其实际 API 的理解是否正确。我们的适配器已经编写了按特定顺序进行某些 API 调用的代码。这个测试验证了这个假设的正确性。如果测试通过,我们就知道我们对外部服务 API 的理解是正确的,而且它没有发生变化。如果这个测试之前是有效的但现在失败了,那将是一个早期迹象,表明外部服务已经更改了其 API。那时,我们需要更新我们的适配器代码以适应这一变化。

用于此的一个推荐工具称为 Pact,可在docs.pact.io找到。阅读那里的指南以获取有关此有趣技术的更多详细信息。

我们已经看到集成测试让我们更接近生产环境。在下一节中,我们将探讨测试金字塔中的最终测试级别,这是迄今为止最接近真实环境的:用户验收测试。

端到端测试和用户验收测试

在本节中,我们将逐步推进到测试金字塔的顶端。我们将回顾端到端测试和用户验收测试是什么,以及它们为单元测试和集成测试增添了什么。

测试金字塔的顶端有两种类似的测试,称为端到端测试用户验收测试。技术上,它们是同一种测试。在每种情况下,我们都启动了完全配置的软件,以在与其最相似的真实测试环境中运行,或者可能在生产环境中运行。想法是,系统从一端到另一端作为一个整体进行测试。

端到端测试的一个特定用途是进行用户验收测试UAT)。在这里,运行了几个关键的端到端测试场景。如果它们都通过了,软件就被宣布适合使用,并被用户接受。这通常是商业开发中的一个合同阶段,其中软件的购买者正式同意开发合同已经得到满足。这仍然是使用精选测试用例进行端到端测试来决定这一点。

这些测试具有优点和局限性,如下表所示:

优点 局限性
最全面的功能测试。我们正在测试的级别与我们的系统用户 – 无论是人还是机器 – 经历我们的系统相同。 运行速度最慢的测试。
在这个级别的测试关注的是从系统外部观察到的纯行为。我们可以重构和重新设计系统的很大一部分,同时仍然有这些测试保护我们。 可靠性问题 – 我们系统设置和环境中的许多问题都可以导致假阴性测试失败。这被称为“脆弱性” – 我们的测试高度依赖于它们的环境正确工作。环境可能由于我们无法控制的情况而损坏。
合同上重要的 – 这些测试是终端用户关心的本质。 这些是所有测试中最具挑战性的,因为它们需要大量的环境设置要求。

表 10.3 – 端到端测试的优点和缺点

在金字塔顶端放置验收测试反映的是我们不需要很多这样的测试。现在,我们的大部分代码应该由单元测试和集成测试覆盖,确保我们的应用程序逻辑以及与外部系统的连接都是正确的。

显然的问题是还需要测试什么?我们不希望重复在单元和集成级别已经完成的测试。但我们需要某种方式来验证软件整体将按预期工作。这是端到端测试的工作。这是我们在配置软件时使其连接到真实数据库和真实外部服务的地方。我们的生产代码已经通过了所有单元测试和测试替身。这些测试通过表明,当我们连接这些真实的外部服务时,我们的代码应该能够正常工作。但应该是软件开发中的一个美好的狡猾词汇。现在,是时候通过端到端测试来验证这一点了。我们可以使用以下图表来表示这些测试的覆盖率:

图 10.6 – 端到端/用户验收测试覆盖整个代码库

图 10.6 – 端到端/用户验收测试覆盖整个代码库

端到端测试覆盖整个代码库,包括领域模型和适配器层。因此,它重复了单元和集成测试已经完成的工作。我们在端到端测试中想要测试的主要技术方面是,我们的软件配置和连接是正确的。在这本书的整个过程中,我们使用了依赖倒置和注入来隔离我们与外部系统。我们创建了测试替身并将它们注入。现在,我们必须创建实际的生成代码,即连接到生产系统的真实适配器层组件。我们在系统的初始化和配置期间将这些注入到我们的系统中。这使代码能够真正地工作。

端到端测试将复制一小部分已经被单元和集成测试覆盖的愉快路径测试。这里的目的是不是验证我们已经测试过的行为。相反,这些测试通过确认整个系统在连接到生产服务时表现正确,来验证我们已经注入了正确的生产对象。

用户验收测试基于这一理念,通过运行关键测试场景来接受软件作为完整产品。这些将在技术层面上进行端到端测试。但他们的目的比确保我们的系统正确配置的技术目标更广泛:我们是否构建了我们被要求构建的内容? 通过结合本书中的迭代方法及其技术实践,我们有更高的可能性做到了这一点。

验收测试工具

存在着各种测试库来帮助我们编写自动化的验收和端到端测试。连接数据库或调用 HTTP Web API 这样的任务对于这类测试来说是常见的。我们可以利用库来完成这些任务,而不是自己编写代码。

这些工具之间的主要区别在于它们与我们软件的交互方式。有些旨在模拟用户点击桌面 GUI 或基于浏览器的 Web UI。其他工具将向我们的软件发出 HTTP 请求,测试 Web 端点。

这里有一些值得考虑的流行验收测试工具:

  • RestEasy

一个流行的用于测试 REST API 的工具:resteasy.dev/

  • RestAssured

另一个流行的用于测试 REST API 的工具,它采用流畅的方法来检查 JSON 响应:rest-assured.io/

  • Selenium

一个流行的通过浏览器测试 Web UI 的工具:www.selenium.dev/

  • Cucumber

可从 cucumber.io/ 获取。Cucumber 允许领域专家用类似英语的描述来编写测试。至少,这是理论。我在参与的任何项目中都没有见过除了开发者之外的人编写 Cucumber 测试。

验收测试构成了测试金字塔的最后一部分,允许我们的应用程序在类似于生产环境的情况下进行测试。所需的一切就是自动化运行所有这些测试层的方法。这就是 CI/CD 流水线发挥作用的地方,它们是下一节的主题。

CI/CD 流水线和测试环境

CI/CD 流水线和测试环境是软件工程的重要组成部分。它们是开发工作流程的一部分,将我们从编写代码带到用户手中的系统。在本节中,我们将探讨这些术语的含义以及我们如何在项目中使用这些想法。

什么是 CI/CD 流水线?

让我们从定义这些术语开始:

  • CI 代表 持续集成

集成是指我们将单个软件组件组合在一起,形成一个整体。持续集成意味着我们在编写新代码时一直这样做。

  • CD 代表持续交付持续部署

我们稍后会讨论两者的区别,但在两种情况下,我们的想法都是将我们集成软件的最新和最佳版本交付给利益相关者。持续交付的目标是,如果我们愿意,我们可以通过点击一个按钮将每一个代码更改部署到生产环境中。

重要的是要注意,CI/CD 是一种工程学科——而不是一系列工具。然而我们如何实现它,CI/CD 的目标是构建一个始终处于可用状态的单一系统。

我们为什么需要持续集成?

在测试金字塔的术语中,我们需要 CI/CD 的原因是将所有测试集中在一起。我们需要一个机制来构建我们软件的整体,使用最新的代码。在我们打包和部署代码之前,我们需要运行所有测试并确保它们全部通过。如果任何测试失败,我们知道代码不适合部署。为了确保我们能够快速获得反馈,我们必须按照从快到慢的顺序运行测试。我们的 CI 管道将首先运行单元测试,然后是集成测试,接着是端到端和验收测试。如果任何测试失败,构建将生成该阶段的测试失败报告,然后停止构建。如果所有测试都通过,我们将打包我们的代码,准备部署。

更普遍地说,集成的概念对于构建软件是基本的,无论我们是单独工作还是在开发团队中工作。当我们单独工作时,遵循本书中的实践,我们正在用几个构建块构建软件。其中一些是我们自己制作的,而对于其他一些,我们选择了合适的库组件并使用了它。我们还编写了适配器——允许我们访问外部系统的组件。所有这些都需要集成——作为一个整体组合在一起——以将我们的代码行转换成一个工作系统。

当在一个团队中工作时,集成甚至更为重要。我们不仅需要将我们编写的部分组合在一起,还需要将团队其他成员编写的所有其他部分也组合在一起。整合同事正在进行的工作是紧急的。我们最终是在别人已经编写的基础上进行构建。当我们工作在主集成代码库之外时,存在不包含最新设计决策和可重用代码片段的风险。

下图展示了持续集成的目标:

图 10.7 – 持续集成

图 10.7 – 持续集成

持续集成的动机是为了避免经典的瀑布式开发陷阱,即团队作为独立的个体编写代码,遵循计划,只在最后进行集成。很多时候,这种集成无法产生可工作的软件。通常存在一些误解或缺失的部分,意味着组件无法配合。在瀑布项目的这个后期阶段,错误修复成本很高。

不仅大型团队和大型项目会受到影响。我的转折点是在为英国皇家空军红箭表演队编写飞行模拟游戏时。我们两个人共同使用我们商定的 API 来开发这款游戏。当我们第一次尝试集成我们的部分时——当然是在凌晨 3 点,在公司总经理面前——游戏运行了大约三帧然后崩溃了。哎呀!我们缺乏持续集成提供了尴尬的教训。如果早点知道会发生这种情况会更好,尤其是在总经理在场的情况下。

为什么我们需要持续交付?

如果持续集成是关于保持我们的软件组件作为一个不断增长的整体的统一,那么持续部署就是将这个整体交付给关心它的人。以下图示说明了持续部署:

图 10.8 – 持续交付

图 10.8 – 持续交付

向最终用户交付一系列价值是敏捷开发的核心原则。无论你使用哪种敏捷方法论,将特性交付给用户始终是目标。我们希望定期、短间隔地交付可用的特性。这样做提供了三个好处:

  • 用户获得他们想要的价值

最终用户并不关心我们的开发过程。他们只关心得到解决他们问题的方案。无论是等待优步乘车时的娱乐问题,还是跨国企业支付所有人工资的问题,我们的用户只想看到问题得到解决。将具有价值的特性带给我们的用户成为一种竞争优势。

  • 我们获得宝贵的 用户反馈

是的,这就是我要求的——但这并不是我的本意! 这是非常宝贵的用户反馈,敏捷方法可以提供。一旦最终用户看到我们实现的功能,有时,它就会变得明显,它并没有完全解决他们的问题。我们可以迅速纠正这一点。

  • 使代码库和 开发团队 保持一致

要完成这项壮举,你需要让你的团队和工作流程协同一致。除非你的工作流程能够产生已知可工作的软件作为一个整体持续可用,否则你无法有效地做到这一点。

持续交付还是持续部署?

这些术语的确切定义似乎各不相同,但我们可以这样思考:

  • 持续交付

我们向内部利益相关者交付软件,例如产品所有者和 QA 工程师

  • 持续部署

我们将软件交付到生产环境和最终用户手中

在这两个中,持续部署设定了一个更高的标准。它要求一旦我们将代码集成到我们的流水线中,该代码就准备好上线——进入生产环境,面向真实用户。这当然是困难的。它需要顶级的测试自动化来让我们对我们的代码准备好部署有信心。它还受益于在生产环境中拥有快速的回滚系统——如果我们发现测试未覆盖的缺陷,有一些快速回滚部署的方法。持续部署是终极工作流程。对于所有实现它的人来说,周五最后部署新代码根本不会感到恐惧。嗯,也许稍微少一点恐惧。

实际的 CI/CD 流水线

大多数项目使用 CI 工具来处理序列任务。流行的工具由 Jenkins、GitLab、CircleCI、Travis CI 和 Azure DevOps 提供。它们都类似地工作,依次执行单独的构建阶段。这就是“流水线”这个名字的由来——它类似于一端装满下一个构建阶段,从管道的另一端出来的管道,如下面的图所示:

图 10.9 – CI 流水线中的阶段

图 10.9 – CI 流水线中的阶段

CI 流水线包括以下步骤:

  1. 源代码控制:有一个共同的存储代码的位置对于 CI/CD 是至关重要的。这是代码集成的地点。流水线从这里开始,通过拉取最新的源代码并执行干净的构建。这防止了由于计算机上存在代码的旧版本而引起的错误。

  2. 在 JVM 上运行的 .jar 文件。

  3. 静态代码分析:代码检查器和其它分析工具检查源代码中的风格违规,例如变量长度和命名约定。开发团队可以选择在静态分析检测到特定的代码问题时失败构建。

  4. 单元测试:所有单元测试都是针对构建后的代码运行的。如果任何测试失败,则流水线停止。测试失败信息会被报告。

  5. 集成测试:所有集成测试都是针对构建后的代码运行的。如果任何测试失败,则流水线停止,并报告错误信息。

  6. 验收测试:所有验收测试都是针对构建后的代码运行的。如果所有测试都通过,则代码被认为是正常工作并准备好交付/部署的。

  7. 包含嵌入式 Web 服务器的 .jar 文件。

接下来会发生什么取决于项目的需求。打包的代码可能会自动部署到生产环境,或者它可能只是放置在一些内部仓库中,供产品所有者和 QA 工程师访问。正式部署将在质量门控制之后发生。

测试环境

需要 CI 管道运行集成测试所引起的一个明显问题是需要一个运行这些测试的地方。通常,在生产环境中,我们的应用程序与外部系统(如数据库和支付提供商)集成。当我们运行 CI 管道时,我们不希望我们的代码处理支付或写入生产数据库。然而,我们确实希望测试代码能够与这些系统集成,一旦我们配置它连接到这些真实系统。

解决方案是创建一个测试环境。这些是我们控制下的数据库和模拟外部系统的集合。如果我们的代码需要与用户详情数据库集成,我们可以创建该用户数据库的副本并在本地运行它。在测试期间,我们可以安排我们的代码连接到这个本地数据库,而不是生产版本。外部支付提供商通常提供沙箱 API。这是他们服务的一个版本,它再次不连接到他们的任何真实客户。它具有模拟其服务的行为。实际上,它是一个外部测试替身。

这种设置被称为类似实时预发布环境。它允许我们的代码在更真实的集成中进行测试。我们的单元测试使用存根和模拟。我们的集成测试现在可以使用这些更丰富的测试环境。

使用测试环境的优势和挑战

测试环境既有优势也有劣势,如下表总结:

优势 挑战
环境 是自包含的我们可以随意创建和销毁它。它不会影响生产系统。 不是 生产环境无论我们如何使其类似实时,这些环境都是模拟。风险是,我们的假环境给出假阳性结果——仅因为它们使用了假数据而通过的测试。这可能会给我们带来错误的信心,导致我们部署在生产环境中会失败的代码。真正的测试发生在我们设置代码为实时时。总是。
比存根更真实环境让我们更接近在生产和条件下进行测试。 创建和维护需要额外努力需要更多开发工作来设置这些环境并保持它们与测试代码同步。
检查关于 外部系统的假设第三方沙箱环境使我们能够确认我们的代码使用了供应商发布的最新、正确的 API。 隐私问题简单地复制生产数据块并不足以用于测试环境。如果这些数据包含根据 GDPR 或 HIPAA 定义的个人身份信息PII),那么我们无法直接合法地使用它。我们必须创建一个额外的步骤来匿名化这些数据或生成伪真实的随机测试数据。这两者都不简单。

表 10.4 – 测试环境的优势和挑战

生产环境中的测试

我已经听到了惊呼声!在生产中运行我们的测试通常是一个糟糕的想法。我们的测试可能会引入假订单,我们的生产系统会将其视为真实订单。我们可能需要添加测试用户账户,这可能会带来安全风险。更糟糕的是,因为我们处于测试阶段,我们的代码可能还没有工作。这可能会引起各种问题——所有这些都是在连接到生产系统的情况下发生的。

尽管有这些担忧,有时,某些事情必须在生产中进行测试。像谷歌和 Meta 这样的大数据公司,由于数据规模巨大,他们有一些东西只能通过实际运行来测试。无法创建一个有意义的类似真实环境的测试环境;它将太小。在这种情况下我们能做什么呢?

该方法是减轻风险。这里有两种技术很有价值:蓝绿部署和流量分区。

蓝绿部署

蓝绿部署是一种旨在快速回滚失败部署的部署技术。它通过将生产服务器分为两组来实现。它们被称为蓝色绿色,因为它们是中性的颜色,都表示成功。我们的生产代码将随时运行在服务器组中的一组。假设我们目前正在运行在蓝色组。我们的下一个部署将是绿色组。如下面的图所示:

图 10.10 – 蓝绿部署

图 10.10 – 蓝绿部署

一旦代码已部署到绿色组,我们就切换生产配置以连接到绿色组服务器。我们在蓝色服务器上保留之前工作的生产代码。如果我们对绿色组的测试顺利,那么我们就完成了。现在生产正在使用最新的绿色组代码。如果测试失败,我们将该配置回滚以再次连接到蓝色服务器。这是一个快速回滚系统,使我们能够进行实验。

流量分区

除了蓝绿部署之外,我们还可以限制发送到测试服务器的流量。我们不必将生产完全切换到测试中的新代码,我们只需将一小部分用户流量发送到那里。所以,99%的用户可能会被路由到我们已知的蓝色服务器,1%可以路由到绿色服务器上正在测试的新代码,如下面的图所示:

图 10.11 – 流量分区

图 10.11 – 流量分区

如果发现缺陷,在我们回滚到 100%蓝色服务器之前,只有 1%的用户会受到 影响。这使我们能够快速回滚,减轻由失败的部署在生产中引起的问题。

我们现在已经涵盖了不同类型测试的角色,并看到了它们如何融入一个被称为测试金字塔的连贯系统。在下一节中,我们将通过编写集成测试将一些知识应用到我们的 Wordz 应用程序中。

Wordz – 我们数据库的集成测试

在本节中,我们将回顾我们的 Wordz 应用程序的集成测试,以了解它们的样子。我们将在第十四章驱动数据库层第十五章驱动 Web 层中详细说明编写这些测试和设置测试工具的细节。

从数据库中获取一个单词

在我们之前的设计工作中,我们确定 Wordz 需要一个地方来存储要猜测的候选单词。我们定义了一个名为WordRepository的接口来隔离我们与存储细节。在那个迭代中,我们只定义了一个接口上的方法:

public interface WordRepository {
String fetchWordByNumber( int wordNumber );
}

这个 WordRepository 接口的实现将访问数据库,并返回一个给定其wordNumber的单词。我们将推迟到第十四章驱动数据库层中实现这一功能。现在,让我们先看看集成测试的大致样子。这个测试使用开源库来帮助编写测试,并提供数据库。我们选择了以下内容:

下面是测试代码:

package com.wordz.adapters.db;
import com.github.database.rider.core.api.connection.ConnectionHolder;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.junit5.api.DBRider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.postgresql.ds.PGSimpleDataSource;
import javax.sql.DataSource;
import static org.assertj.core.api.Assertions.assertThat;
@DBRider
public class WordRepositoryPostgresTest {
    private DataSource dataSource;
    @BeforeEach
    void beforeEachTest() {
        var ds = new PGSimpleDataSource();
        ds.setServerNames(new String[]{"localhost"});
        ds.setDatabaseName("wordzdb");
        ds.setUser("ciuser");
        ds.setPassword("cipassword");
        this.dataSource = ds;
    }
    private final ConnectionHolder connectionHolder = () ->
        dataSource.getConnection();
    @Test
    @DataSet("adapters/data/wordTable.json")
    public void fetchesWord()  {
        var adapter = new WordRepositoryPostgres(dataSource);
        String actual = adapter.fetchWordByNumber(27);
        assertThat(actual).isEqualTo("ARISE");
    }
}

fetchesWord()测试方法由@DataSet注解标记。这个注解由database-rider测试框架提供,并形成我们测试的安排步骤。它指定了一个已知测试数据文件,该框架将在测试运行之前将其加载到数据库中。数据文件位于src/test/resources根文件夹下。注解中的参数给出了其余路径。在我们的情况下,文件将位于src/test/resources/adapters/data/wordTable.json。其内容如下:

{
  "WORD": [
    {
      "id": 1,
      "number": 27,
      "text": "ARISE"
    }
  ]
}

这个 JSON 文件告诉database-rider框架,我们希望向名为WORD的数据库表中插入一行,列值为127ARISE

我们现在还不打算编写适配器代码来使这个测试通过。我们需要采取几个步骤来使这个测试能够编译,包括下载各种库和启动 Postgres 数据库。我们将在第十四章驱动数据库层中详细说明这些步骤。

这段集成测试代码的概述是,它正在测试一个名为WordRepositoryPostgres的新类,这是我们将要编写的。这个类将包含数据库访问代码。我们可以看到标志性的 JDBC 对象,javax.sql.DataSource,它代表一个数据库实例。这是我们在测试与数据库集成时的线索。我们可以看到来自数据库测试库的新注解:@DBRider@DataSet。最后,我们可以看到一些立即可以识别的东西——测试的安排、行动和断言步骤:

  1. 安排步骤创建一个WordRepositoryPostgres对象,它将包含我们的数据库代码。它使用database-rider库的@DataSet注解在测试运行之前将一些已知数据放入数据库中。

  2. 行动步骤调用fetchWordByNumber()方法,传入我们想要测试的数字wordNumber。这个数字与wordTable.json文件的内容相匹配。

  3. 断言步骤确认从数据库返回的预期单词,ARISE

如我们所见,集成测试在本质上与单元测试并没有太大的不同。

摘要

在本章中,我们看到了测试金字塔是如何作为一个系统来组织我们的测试努力的,它坚定地将 FIRST 单元测试作为我们所有工作的基础,但并没有忽视其他测试关注点。首先,我们介绍了集成测试和验收测试作为测试我们系统更多部分的方法。然后,我们探讨了 CI 和 CD 技术如何保持我们的软件组件在一起,并频繁地准备好发布。我们看到了如何使用 CI 管道将整个构建过程整合在一起,可能继续到 CD。我们在 Wordz 上取得了一点点进展,通过为WordRepositoryPostgres适配器编写集成测试,为我们编写数据库代码本身奠定了基础。

在下一章中,我们将探讨手动测试在我们项目中的作用。现在很明显,我们将尽可能多地自动化测试,这意味着手动测试的角色不再意味着遵循庞大的测试计划。然而,手动测试仍然非常有价值。这个角色是如何变化的?我们将在下一章回顾。

问题与答案

以下是一些关于本章材料的问题及其答案:

  1. 为什么测试金字塔被表示为金字塔形状?

形状描述了一个由许多单元测试组成的广泛基础。它显示了在那些测试更高层次集成系统的测试层。它还显示我们预计在那些更高层次的集成级别上测试较少。

  1. 单元测试、集成测试和验收测试之间的权衡是什么?

    • 单元测试:快速、可重复。不要测试与外部系统的连接。

    • 集成测试:较慢,有时不可重复。它们测试与外部系统的连接。

    • 验收测试:所有测试中最慢的。它们可能不可靠,但提供了对整个系统最全面的测试。

  2. 测试金字塔是否保证了正确性?

不。测试只能揭示缺陷的存在,永远不能揭示其不存在。广泛测试的价值在于我们避免了多少缺陷进入生产环境。

  1. 测试金字塔是否仅适用于面向对象编程?

不。这种测试覆盖率策略适用于任何编程范式。我们可以使用任何范式编写代码 - 面向对象、函数式、过程式或声明式。各种类型的测试只取决于我们的代码是否访问外部系统或仅由内部组件组成。

  1. 为什么我们不优先选择端到端测试,尽管它们测试整个系统?

端到端测试运行缓慢。它们直接依赖于生产数据库和 Web 服务的运行,或者运行包含那些事物测试版本的测试环境。所需的网络连接以及诸如数据库设置之类的事情可能导致测试给出错误阴性结果。它们失败是因为环境,而不是因为代码错误。由于这些原因,我们设计系统以最大限度地利用快速、可重复的单元测试。

进一步阅读

要了解更多关于本章所涵盖的主题,请查看以下资源:

  • 消费者驱动测试简介

Pact.io 在其网站上提供了一种流行的开源合同测试工具,docs.pact.io。该网站提供了解释视频和关于合同驱动测试益处的有用介绍。

  • 数据库-rider 数据库测试库

一个与 JUnit5 兼容的开源数据库集成测试库。它可以从database-rider.github.io/getting-started/获取。

  • 现代软件工程,Dave FarleyISBN 978-0137314911

本书详细解释了持续交付背后的原因以及各种技术实践,如基于主干的开发,以帮助我们实现这一目标。强烈推荐。

  • 最小化持续交付

关于持续交付所需信息的详细信息:minimumcd.org/minimumcd/.

第十一章:与质量保证一起探索 TDD

前几章介绍了设计和测试精心设计代码所需的技术实践。所提出的方法主要是为了让开发者能够快速获得软件设计的反馈。测试几乎成了这些努力的副产品。

TDD、持续集成和管道的组合为我们提供了对代码的高度信心。但当谈到软件质量保证QA)时,它们并不是整个图景。创建最高质量的软件需要额外的流程,包括人类接触。在本章中,我们将强调手动探索性测试、代码审查、用户体验和安全测试的重要性,以及将人类决策点添加到软件发布中的方法。

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

  • TDD – 在更广泛的质量图景中的位置

  • 手动探索性测试 – 发现意外情况

  • 代码审查和集成编程

  • 用户界面和用户体验测试

  • 安全测试和操作监控

  • 将手动元素融入 CI/CD 工作流程

TDD – 在更广泛的质量图景中的位置

在本节中,我们将批判性地审视 TDD 为测试表带来了什么,以及哪些仍然是人类活动。虽然 TDD 无疑作为测试策略的一部分具有优势,但它永远不能成为成功软件系统的整个策略。

理解 TDD 的局限性

从主流开发的角度来看,TDD 是一个相对较新的学科。TDD 的现代起源与 Kent Beck 在克莱斯勒综合薪酬系统(参见进一步阅读部分,其中介绍了测试优先单元测试的想法)有关。该项目始于 1993 年,Kent Beck 的参与始于 1996 年。

克莱斯勒综合薪酬项目以广泛使用单元测试驱动小型迭代和频繁发布代码为特点。希望我们能从本书前几章中认识到这些想法。自那时以来,许多事情都发生了变化——部署选项不同了,用户数量增加了,敏捷方法更为常见——但测试的目标保持不变。这些目标是为了推动正确的、精心设计的代码,并最终满足用户的需求。

测试自动化的替代方案是在没有自动化的情况下运行测试——换句话说,手动运行它们。一个更好的术语可能是人类驱动的。在测试自动化变得普遍之前,任何开发计划的一个重要部分都是测试策略文档。这些冗长的文档定义了何时进行测试,如何进行测试,以及谁将进行这些测试。

这份策略文档与详细的测试计划并存。这些也会是书面文件,描述要执行的所有测试——如何设置,要测试的确切步骤,以及预期的结果应该是什么。传统的瀑布式项目会花费大量时间来定义这些文档。在某种程度上,这些文档与我们的 TDD 测试代码类似,只是写在纸上,而不是源代码中。

执行这些手动测试计划是一项巨大的工作。运行测试需要我们手动设置测试数据,运行应用程序,然后通过用户界面进行点击。结果必须被记录。发现的缺陷必须记录在缺陷报告中。这些必须反馈到瀑布模型中,触发重新设计和重新编码。这必须发生在每个单独的版本中。人工驱动的测试是可重复的,但代价巨大,需要准备、更新和跟踪测试文档。这一切都花费了时间——而且很多时间。

在这个背景下,贝克的 TDD 想法似乎非常引人注目。测试文档变成了可执行的代码,可以按需运行,成本仅为人工测试的一小部分。这是一个令人信服的愿景。测试代码的责任现在成为了开发者世界的一部分。这些测试是源代码本身的一部分。这些测试是自动化的,能够在每次构建时完全运行,并且随着代码的变化而保持更新。

是否不再需要手动测试?

很容易认为,使用本书中描述的 TDD 可能会消除手动测试。它确实消除了某些手动过程,但绝对不是全部。我们用自动化取代的主要手动步骤是在开发期间进行的功能测试和发布前的回归测试。

当我们使用 TDD 开发新功能时,我们首先为该功能编写自动化测试。我们编写的每个自动化测试都不需要手动运行。我们节省了所有这些测试设置时间,以及通常需要通过用户界面点击来触发我们正在测试的行为的漫长过程。TDD 带来的主要区别是用在 IDE 中编写的测试代码替换了在文字处理程序中编写的测试计划。开发功能的手动测试被自动化所取代。

TDD(测试驱动开发)还为我们提供了免费的自动化回归测试:

图 11.1 – 回归测试

图 11.1 – 回归测试

使用 TDD,我们在构建每个功能时添加一个或多个测试。值得注意的是,我们保留了所有这些测试。我们自然地建立了一个庞大的自动化测试套件,这些测试被捕获在源代码控制中,并在每次构建时自动执行。这被称为回归测试套件。回归测试意味着我们在每次构建时重新检查迄今为止运行的测试。这确保了当我们对系统进行更改时,我们不会破坏任何东西。快速移动且不破坏东西可能就是我们对这种方法的描述。

回归测试还包括对之前报告的缺陷的测试。这些回归测试确认它们没有被重新引入。需要强调的是,回归测试套件在每次套件执行时都节省了非自动化测试所需的全部手动工作。在整个软件生命周期中,这会导致巨大的减少。

测试自动化是好的,但自动化测试是一个软件机器。它不能自己思考。它不能视觉检查代码。它不能评估用户界面的外观。它不能判断用户体验是好是坏。它不能确定整个系统是否适合使用。

这就是人类驱动的手动测试介入的地方。以下章节将探讨我们需要人类引导测试的领域,从显而易见的一个开始:找出测试遗漏的缺陷。

手动探索性测试 – 发现意外情况

在本节中,我们将欣赏手动探索性测试在 TDD(测试驱动开发)中作为防御缺陷的重要防线的作用。

对我们使用 TDD 成功最大的威胁在于我们思考所有软件需要处理的条件的能力。任何合理的复杂软件都有巨大的可能输入组合、边缘情况和配置选项。

考虑使用 TDD 编写代码来限制将产品销售给 18 岁及以上买家的销售。我们必须首先编写一个 Happy-path 测试来检查销售是否允许,使其通过,然后编写一个负面测试,确认基于年龄可以阻止销售。这个测试具有以下形式:

public class RestrictedSalesTest {
    @Test
    void saleRestrictedTo17yearOld() {
        // ... test code omitted
    }
    @Test
    void salePermittedTo19yearOld() {
        // ... test code omitted
    }
}

当我们在寻找错误时,错误是明显的:在 17 岁和 18 岁之间的边界会发生什么?一个 18 岁的成年人可以购买这个产品吗?我们不知道,因为没有针对那个年龄段的测试。我们测试了 17 岁和 19 岁的人。就那个边界而言,应该发生什么?通常,这是一个利益相关者的决定。

自动化测试无法完成两件事:

  • 询问利益相关者他们希望软件做什么

  • 发现遗漏的测试

这就是手动探索性测试介入的地方。这是一种充分利用人类创造力的测试方法。它利用我们的本能和智慧来确定我们可能遗漏的测试。然后,它使用科学实验来验证我们对遗漏测试的预测是否正确。如果得到证实,我们可以对这些发现提供反馈并修复缺陷。这可以通过非正式讨论或使用正式的缺陷跟踪工具来完成。在适当的时候,我们可以编写新的自动化测试来捕捉我们的发现,并为未来提供回归测试。

这种探索性测试是一项高度技术性的工作,基于对软件系统中存在哪些类型的边界的了解。它还要求对本地部署和软件系统的设置有广泛的知识,以及了解软件是如何构建的,以及缺陷可能出现的地点。在一定程度上,它依赖于了解开发者的思维方式,并预测他们可能忽略的事情。

自动化测试和探索性测试之间的一些关键差异可以总结如下:

自动化测试 手动 探索性测试
可重复 创造性
对已知结果的测试 发现未知结果
可由机器完成 需要人类创造力
行为验证 行为调查
计划 机会主义
代码控制测试 人类思维控制测试

表 11.1 – 自动化与手动探索性测试

手动探索性测试始终是必要的。即使是最好的开发者也会因为时间紧迫、分心或有另一个应该通过电子邮件召开的会议而感到压力。一旦注意力分散,错误就很容易悄悄溜进来。一些遗漏的测试与我们不能单独看到的边缘情况有关。另一个人类视角常常带来我们单凭自己的能力永远不会有的新见解。手动探索性测试为防止缺陷未被发现提供了重要的一层深度防御。

一旦探索性测试确定了某些意外的行为,我们可以将其反馈到开发中。在那个时刻,我们可以使用 TDD 来编写正确行为的测试,确认缺陷的存在,然后开发修复方案。我们现在有一个修复方案和一个回归测试来确保错误得到修复。我们可以将手动探索性测试视为我们遗漏的缺陷最快可能的反馈循环。关于探索性测试的优秀指南列在进一步阅读部分。

从这个角度来看,自动化测试和 TDD 并没有使手动工作变得不重要。相反,它们的价值得到了放大。这两种方法共同工作,将质量融入代码库。

对我们遗漏的事情进行手动测试并不是唯一有价值的发展时间活动,不能自动化。我们还有检查我们源代码质量的任务,这是下一节的主题。

代码审查和集体编程

本节回顾了另一个对自动化抵抗性惊人的领域:检查代码质量。

如本书所述,TDD(测试驱动开发)主要关注我们代码的设计。当我们构建单元测试时,我们定义了我们的代码将如何被消费者使用。该设计的实现对我们测试来说无关紧要,但它确实关系到我们作为软件工程师。我们希望该实现能够高效运行,并且易于下一位读者理解。代码在其生命周期中读的次数远多于写的次数。

一些自动化工具存在,可以帮助检查代码质量。这些被称为静态代码分析工具。这个名字来源于它们不运行代码;相反,它们对源代码进行自动审查。Java 的一个流行工具是 Sonarqube(在www.sonarqube.org/),它在一组代码库上运行一系列规则。

默认情况下,此类工具会警告以下内容:

  • 不遵循变量命名约定

  • 未初始化的变量可能导致可能的NullPointerException问题

  • 安全漏洞

  • 程序结构的低效或风险使用

  • 违反社区公认的做法和标准

这些规则可以修改和添加,以便根据本地项目风格和规则进行定制。

当然,这种自动化评估也有局限性。与手动探索性测试一样,有些事情只有人类才能做到(至少在撰写本文时是这样)。在代码分析方面,这主要涉及到将上下文带入决策中。这里的一个简单例子是,相比于int这样的原始类型,更倾向于使用更长、更具描述性的变量名,例如WordRepository。静态工具缺乏对不同上下文的理解。

自动代码分析有其优点和局限性,如下总结:

自动分析 人工审查
严格的规则(例如,变量名长度) 根据上下文放宽规则
应用固定的评估标准 应用经验学习
报告通过/失败结果 建议替代改进

表 11.2 – 自动分析与人工审查

谷歌有一个非常有趣的系统,称为谷歌三频仪。这是一套程序分析工具,它结合了谷歌工程师在制定良好代码规则方面的创造力以及自动化应用这些规则。更多信息,请参阅research.google/pubs/pub43322/

人工审查代码可以以各种方式进行,以下是一些常见的方法:

  • 拉取请求 上进行代码审查

当开发者希望将最新的代码更改集成到主代码库中时,他们会发起一个拉取请求,也称为合并请求。这为另一位开发者提供了一个审查这项工作并提出改进的机会。他们甚至可以直观地发现缺陷。一旦原始开发者做出一致的改变,请求就会被批准,代码就会被合并。

  • 结对编程

结对编程是一种工作方式,其中两位开发者同时处理同一项任务。他们持续讨论如何以最佳方式编写代码。这是一个持续审查的过程。一旦任何一位开发者发现问题或提出改进建议,就会发生讨论并做出决定。代码在开发过程中不断得到纠正和改进。

  • 团队编程(****mob)

就像结对编程一样,整个团队都参与编写一个任务的代码。这是协作的极致,它不断地将整个团队的专业知识和意见应用于每一行代码。

这里戏剧性的区别在于代码审查发生在代码编写之后,但结对编程和团队编程发生在代码编写过程中。编写代码后的代码审查通常发生得太晚,无法进行有意义的更改。结对和团队编程通过持续审查和改进代码来避免这种情况。一旦发现更改,就会立即进行更改。这可能导致与代码后审查工作流程相比,更早地交付更高质量的输出。

不同的开发情况将采用不同的实践。在每种情况下,添加第二双(或更多)人眼提供了一个机会,进行设计层面的改进,而不是语法层面的改进。

通过这样,我们已经看到了开发者如何通过添加手动探索性测试和代码审查到他们的 TDD 工作中受益。手动技术也对我们用户有益,我们将在下一节中介绍。

用户界面和用户体验测试

在本节中,我们将考虑如何评估我们的用户界面对用户的影响。这是另一个自动化带来好处但无法在没有人类参与的情况下完成工作的领域。

测试用户界面

用户界面是我们软件系统中唯一对所有人最重要的部分:我们的用户。它们——字面意义上——是他们通向我们世界的窗口。无论我们有一个命令行界面、移动网页应用还是桌面 GUI,我们的用户都会因为我们的用户界面而得到帮助或受阻。

用户界面的成功取决于两个方面都做得很好:

  • 它提供了用户需要的(和想要的)所有功能

  • 它使用户能够以有效和高效的方式完成他们的最终目标

这两个中的第一个,提供功能,是两个中更程序化的一个。就像我们使用 TDD 来推动我们服务器端代码的良好设计一样,我们也可以在我们的前端代码中使用它。如果我们的 Java 应用程序生成 HTML(称为服务器端渲染)——TDD 的使用变得非常简单。我们测试 HTML 生成适配器,然后完成。如果我们正在使用在浏览器中运行的 JavaScript/TypeScript 框架,我们可以在那里使用 TDD,使用如 Jest([jestjs.io/](https://jestjs.io/))这样的测试框架。

在测试了确保我们向用户提供正确的功能后,自动化变得不那么有用。使用 TDD,我们可以验证我们的用户界面中是否包含所有正确的图形元素。但我们无法判断它们是否满足用户的需求。

考虑这个与我们的 Wordz 应用程序相关的商品购买虚构用户界面:

图 11.2 – 示例用户界面

图 11.2 – 示例用户界面

我们可以使用 TDD 来测试所有这些界面元素——框和按钮——是否存在并且工作正常。但我们的用户会在意吗?以下是我们需要提出的问题:

  • 它看起来和感觉好吗?

  • 它是否与公司品牌和风格指南一致?

  • 对于购买 T 恤的任务,它是否易于使用?

  • 它是否向用户呈现一个逻辑流程,引导他们完成任务?

在这个例子中,我们故意对所有这些问题回答“不”。坦白说,这是一个糟糕的用户界面布局。它没有风格,没有感觉,也没有品牌识别度。你必须将产品名称输入到文本字段中。没有产品图片,没有描述,也没有价格!对于电子商务产品的销售页面来说,这个用户界面真的是最糟糕的想象。然而,它将通过我们所有的自动化功能测试。

设计有效的用户界面是一项非常人类化的技能。它涉及到一点心理学,了解人类在面临任务时的行为,结合艺术眼光,并辅以创造力。这些用户界面的特性最好由人类来评估,这为我们的发展过程增加了另一个手动步骤。

评估用户体验

用户界面设计与用户体验设计密切相关。

用户体验超越了用户界面上任何单个元素或视图。它是我们用户从开始到结束的整个体验。当我们想要从我们的电子商务商店订购最新的 Wordz T 恤时,我们希望整个过程都很容易。我们希望每个屏幕上的工作流程都明显、无杂乱,并且比出错更容易正确。

确保用户有良好的体验是用户体验设计师的工作。这是一项结合同理心、心理学和实验的人类活动。自动化在这里的帮助有限。一些机械部分可以自动化。明显的候选者包括 Invision(www.invisionapp.com/)这样的应用程序,它允许我们制作可以交互的屏幕原型,以及 Google 表单,它允许我们在网上收集反馈,无需编写代码来设置。

在创建候选用户体验后,我们可以设计实验,让潜在用户完成一项任务,然后要求他们提供关于他们如何体验的反馈。

一个简单的手动表单足以捕捉这些反馈:

经验 1(差)- 5(好) 评论
我的任务很容易完成 4 在你的研究人员的提示下,我完成了任务。
我在没有说明的情况下自信地完成了我的任务 2 关于 T 恤尺寸的文本输入字段让我困惑。它能否是一个包含可用选项的下拉菜单?
界面引导我完成任务 3 最后还可以接受——但那个文本字段很烦人,所以我给这个任务打了较低的分数。

表 11.3 – 用户体验反馈表

用户体验设计主要是一种人类活动。测试结果的评估也是如此。这些工具只能让我们创建我们的愿景的模拟,并收集实验结果。我们必须与真实用户进行会话,征求他们对体验的看法,然后将结果反馈到改进的设计中。

虽然用户体验很重要,但下一节将讨论我们代码的一个关键任务方面:安全和运营。

安全测试和运营监控

本节反思了安全和运营关注的重点方面。

到目前为止,我们已经创建了一个工程良好且缺陷极低的软件应用。我们的用户体验反馈是积极的——它易于使用。但如果我们不能保持应用正常运行,所有这些潜力都可能在一瞬间消失。如果黑客攻击我们的网站并伤害用户,情况会变得更糟。

一个不运行的应用程序不存在。运营学科——如今通常被称为 DevOps——旨在保持应用程序健康运行,并在健康开始恶化时提醒我们。

安全测试——也称为渗透测试pentesting)——是手动探索性测试的一个特殊案例。根据其本质,我们正在寻找应用中的新漏洞和未知漏洞。这种工作不适合自动化。自动化重复已知的内容;要发现未知的内容需要人类的创造力。

渗透测试是一项学科,它试图绕过软件的安全措施。安全漏洞可能对公司造成昂贵的损失、尴尬或业务中断。用于创建漏洞的攻击通常非常简单。

安全风险可以大致总结如下:

  • 我们不应该看到的事情

  • 我们不应该改变的事情

  • 我们不应该经常使用的事情

  • 我们不应该撒谎的事情

当然,这是一个过于简化的说法。但事实仍然是,我们的应用可能容易受到这些破坏性活动的影响——我们需要知道这是否属实。这需要测试。这种测试必须是适应性、创造性、狡猾的,并且需要不断更新。自动化方法不具备这些特点,这意味着安全测试必须成为我们开发过程中的一个手动步骤。

一个很好的起点是回顾最新的OWASP Top 10 网络应用安全风险(owasp.org/www-project-top-ten/),并根据列出的风险开始一些基于风险的手动探索性测试。有关欺骗、篡改、否认、信息泄露、拒绝服务和权限提升STRIDE)等威胁模型的信息,可以在www.eccouncil.org/threat-modeling/找到。OWASP 还在 https://owasp.org/www-community/Fuzzing 提供了一些关于有用工具的优秀资源。模糊测试是一种自动发现缺陷的方法,尽管它需要人工解释失败的测试结果。

与其他手动探索性测试一样,这些临时实验可能会导致一些未来的测试自动化。但真正的价值在于应用于调查未知事物的创造力。

前面的章节已经说明了手动干预对于补充我们的测试自动化努力的重要性。但这是如何与持续集成/持续交付CI/CD)方法相匹配的呢?这就是下一节的重点。

将手动元素纳入 CI/CD 工作流程

我们已经看到,不仅手动过程在我们的整体工作流程中很重要,而且在某些事情上,它们是不可替代的。但手动步骤如何适应高度自动化的工作流程呢?这就是本节将解决的问题。

将手动流程集成到自动化的 CI/CD 管道中可能很困难。从线性、可重复的活动序列来看,这两种方法并不是天然伙伴。我们采取的方法取决于我们的最终目标。我们是否想要一个完全自动化的持续部署系统,或者我们对一些手动中断感到满意?

将手动流程纳入的最简单方法是简单地在一个合适的位置停止自动化,开始手动流程,然后在手动流程完成后恢复自动化。我们可以将这视为一个阻塞工作流程,因为在管道中的所有后续自动化步骤都必须停止,直到手动工作完成。这在下图中得到了说明:

图 11.3 – 阻塞工作流程

图 11.3 – 阻塞工作流程

通过将我们的开发过程组织为一系列阶段,其中一些是自动化的,一些是手动的,我们创建了一个简单的阻塞工作流程。这里的阻塞意味着每个阶段都会阻塞价值流。自动化阶段通常比手动阶段运行得更快。

这个工作流程有一些优点,那就是它简单易懂且易于操作。我们交付的每个软件迭代都将运行所有自动化测试以及所有当前的手动流程。从某种意义上说,这个发布是我们当时能做出的最高质量。缺点是每个迭代必须等待所有手动流程完成:

图 11.4 – 双轨工作流程

图 11.4 – 双轨工作流程

为了实现非常顺畅的双轨工作流程,一个启用方法是使用整个代码库的单个主分支。所有开发者都提交到这个主分支。没有其他分支。任何正在开发中的功能都通过运行时的true or false进行隔离。代码检查这些标志并决定是否运行功能。然后可以进行手动测试,而无需暂停部署。在测试期间,正在开发中的功能通过相关的功能标志启用。对于一般最终用户,正在开发中的功能被禁用。

我们可以选择最适合我们的交付目标的方法。阻塞工作流程以更长的交付周期为代价,减少了返工。双轨方法允许更频繁地交付功能,但存在风险,即在手动过程发现并修复之前,生产中可能存在缺陷。

选择合适的流程涉及在功能发布周期和容忍缺陷之间进行权衡。无论我们选择什么,目标都是将整个团队的专长集中在创建低缺陷率的软件上。

在平衡自动化工作流程与人工、人类工作流程之间并不容易,但这确实能够将最多的人类直觉和经验融入产品中。这对我们的开发团队和用户都是有益的。他们从应用中获得了改进的易用性和鲁棒性。希望这一章已经向你展示了我们如何结合这两个世界,跨越传统的开发者-测试者之间的鸿沟。我们可以打造一个优秀的团队,致力于实现一个卓越的结果。

摘要

本章讨论了在开发过程中各种手动过程的重要性。

尽管 TDD 有其优点,但我们已经看到了 TDD 无法防止软件中所有类型的缺陷。首先,我们讨论了将人类创造力应用于手动探索性测试的好处,在那里我们可以发现我们在 TDD 中遗漏的缺陷。然后,我们强调了代码审查和分析带来的质量改进。我们还讨论了创建和验证具有令人满意的用户体验的优秀用户界面的非常手动性质。接下来,我们强调了在保持实时系统良好运行方面进行安全测试和运营监控的重要性。最后,我们回顾了将手动步骤集成到自动化工作流程中的方法,以及我们需要做出的权衡。

在下一章中,我们将回顾一些与何时何地开发测试相关的工作方式,然后进入本书的第三部分,在那里我们将完成我们的 Wordz 应用程序的构建。

问题和答案

以下是一些关于本章内容的问题和答案:

  1. TDD 和 CI/CD 管道是否消除了手动测试的需要?

不。它们已经改变了价值所在的地方。一些手动流程已经变得无关紧要,而另一些则变得更加重要。传统上,手动步骤,如遵循测试文档进行功能测试和回归测试,现在不再需要。运行功能和回归测试已经从在文字处理器中编写测试计划转变为在集成开发环境(IDE)中编写测试代码。但对于许多以人为中心的任务来说,在循环中保持人类思维对于成功至关重要。

  1. 人工智能(AI)会自动化剩余的任务吗?

这是个未知数。在 2020 年代初,人工智能的进步可能会改善视觉识别和静态代码分析。可以想象,人工智能图像分析有一天可能能够提供关于可用性的良好/不良分析——但这纯粹是基于人工智能今天生成艺术作品的能力的推测。这种事情可能仍然是不可能的。就目前的实际建议而言,假设本章中推荐的手动流程在一段时间内仍将保持手动操作。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

Kent Beck 对现代 TDD 起源的概述。虽然这些想法确实早于这个项目,但这却是现代 TDD 实践的中央参考。这篇论文包含了关于软件开发和团队的重要见解——包括“让它运行,让它正确,让它快速”的引言,以及我们不应该总感觉像是在工作的必要性。值得一读。

一本关于产品管理的有趣书籍。虽然这在 TDD 开发者书籍中可能看起来有些奇怪,但本章中的许多想法都来自在双轨敏捷项目中跟随这本书的开发者经验。双敏捷意味着在功能发现上的快速反馈循环会输入到快速的敏捷/TDD 交付中。本质上,手动 TDD 是在产品需求级别进行的。这本书是关于现代产品管理的有趣读物,它采用了 TDD 原则来快速验证关于用户功能的假设。本章中的许多想法旨在提高产品级别的软件。

第十二章:测试先行,测试后行,从不测试

在本章中,我们将回顾测试驱动开发TDD)的一些细微差别。我们已经涵盖了编写单元测试的整体测试策略中的广泛技术。我们可以使用测试金字塔和六边形架构来指导测试的范围,具体来说,它们需要覆盖什么。

我们还需要决定两个额外的维度:何时以及在哪里开始测试。第一个问题是关于时间的问题。我们应该总是在编写代码之前编写测试吗?在代码之后编写测试会有什么不同?实际上,完全不测试——这有没有意义?在哪里开始测试是另一个需要决定的变量。在 TDD(测试驱动开发)方面,有两种不同的观点——从内部到外部或从外部到内部进行测试。我们将回顾这些术语的含义以及它们对我们工作的影响。最后,我们将考虑这些方法如何与六边形架构结合,形成一个自然的测试边界。

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

  • 先添加测试

  • 我们不是总能稍后测试吗?

  • 测试?那是给不会写代码的人准备的!

  • 从内部到外部进行测试

  • 从外部到内部进行测试

  • 使用六边形架构定义测试边界

先添加测试

在本节中,我们将回顾在编写生产代码之前先添加测试以使其通过时的权衡。

前几章遵循了测试先行的编写代码方法。我们在编写生产代码之前编写测试以使其通过。这是一个推荐的方法,但了解与之相关的某些困难以及考虑其好处同样重要。

测试先行是一种设计工具

编写测试先行的最重要的好处是测试可以作为设计辅助工具。当我们决定在测试中写什么时,我们正在设计代码的接口。以下图表展示了每个测试阶段如何帮助我们考虑软件设计的各个方面:

图 12.1 – 测试先行辅助设计

图 12.1 – 测试先行辅助设计

安排步骤帮助我们思考待测试代码与整个代码库的更大图景之间的关系。这一步骤帮助我们设计代码将如何融入整个代码库。它给我们机会做出以下设计决策:

  • 需要哪些配置数据?

  • 需要与其他对象或函数建立什么联系?

  • 这段代码应该提供什么行为?

  • 提供该行为需要哪些额外的输入?

编码行为步骤使我们能够思考我们的代码将有多容易使用。我们反思我们希望我们正在设计的代码的方法签名是什么。理想情况下,它应该是简单且明确的。以下是一些一般性建议:

  • 方法名应该描述调用该方法的结果。

  • 尽可能地传递尽可能少的参数。可能将参数分组到它们自己的对象中。

  • 避免使用布尔标志来修改代码的行为。使用具有适当名称的单独方法。

  • 避免要求多次方法调用来完成一项任务。如果我们不熟悉代码,很容易错过序列中的重要调用。

编写行为步骤允许我们看到我们的代码在首次使用时将如何调用。这为我们提供了在代码被广泛使用之前简化并澄清的机会。

我们断言步骤中的代码是我们代码结果的第一消费者。我们可以从这个步骤判断这些结果是否容易获得。如果我们对断言代码的外观不满意,这是一个审查我们的对象如何提供输出的机会。

我们编写的每个测试都提供了这种设计审查的机会。TDD(测试驱动开发)的全部意义在于帮助我们发现更好的设计,甚至比测试正确性更重要。

在其他行业,如汽车设计,拥有专门的设计工具是很常见的。AutoCAD 3D Studio用于在计算机上创建汽车底盘的 3D 模型。在我们制造汽车之前,我们可以使用这个工具来预览最终结果,通过旋转空间并从多个角度观看。

在主流的商业软件开发中,设计工具的支持远远落后。我们没有用于设计代码的 3D Studio 的等价物。从 20 世纪 80 年代到 2000 年代,计算机辅助软件工程CASE工具的兴起,但这些似乎已经不再使用。CASE 工具声称通过允许用户输入各种图形形式的软件结构,然后生成实现这些结构的代码来简化软件工程。今天,在编写生产代码之前编写 TDD 测试似乎是我们目前拥有的最接近软件计算机辅助设计的工具。

测试形成可执行的规范

测试代码的另一个优点是它可以形成一种高度准确、可重复的文档形式。为了实现这一点,测试代码需要简洁和清晰。我们不是编写测试计划文档,而是将 TDD 测试作为代码编写,这些代码可以被计算机运行。这为开发者提供了更直接的好处。这些可执行规范与它们测试的生产代码一起捕获,存储在源代码控制中,并持续提供给整个团队。

进一步的文档很有用。例如,RAID 日志——记录风险、行动、问题和决策——以及KDDs——记录关键设计决策——通常是必需的。这些是非可执行文档。它们的作用是捕捉谁、何时以及关键地为什么做出了重要决策。这类信息无法通过测试代码来捕捉,这意味着这类文档具有价值。

测试驱动提供了有意义的代码覆盖率指标

在编写生产代码之前编写测试,为每个测试赋予一个特定的目的。测试的存在是为了驱除我们代码中的特定行为。一旦我们使这个测试通过,我们就可以使用代码覆盖率工具运行测试套件,这将输出一个类似于以下报告的报表:

图 12.2 – 代码覆盖率报告

图 12.2 – 代码覆盖率报告

代码覆盖率工具在我们运行测试时对我们的生产代码进行仪器化。这种仪器化捕获了在测试运行期间执行了哪些代码行。这份报告可以建议我们缺少测试,通过标记出在测试运行期间从未执行过的代码行。

图像中的代码覆盖率报告显示,我们的测试运行已经执行了领域模型中的 100%的代码。拥有 100%的覆盖率完全取决于我们在编写代码之前编写 TDD 测试来使其通过。我们不会在 TDD 测试优先的工作流程中添加未经测试的代码。

谨防将代码覆盖率指标作为目标

高代码覆盖率指标并不总是意味着高代码质量。如果我们正在为生成的代码或从库中提取的代码编写测试,那么这种覆盖率并不能告诉我们任何新的信息。我们可能假设——通常——我们的代码生成器和库已经由它们的开发者进行了测试。

然而,代码覆盖率数字的一个真正问题是当我们将其作为指标强制执行时。一旦我们向开发者强加一个最低覆盖率目标,那么Goodhart 定律就适用了——当一项指标成为目标时,它就不再是好的指标了。在压力之下,人类有时会作弊以实现目标。当这种情况发生时,你会看到这样的代码:

public class WordTest {
    @Test
    public void oneCorrectLetter() {
        var word = new Word("A");
        var score = word.guess("A");
        // assertThat(score).isEqualTo(CORRECT);
    }
}

注意那些在assertThat()之前的注释符号——//?这是测试案例失败的标志,无法在某个截止日期前通过。通过保留测试,我们保持测试用例的数量,并保持代码覆盖率百分比。这样的测试将执行生产代码的行,但它不会验证它们是否工作。代码覆盖率目标将被达到——即使代码本身并不工作。

现在,我知道你在想什么——没有开发者会像这样作弊测试代码。然而,这确实是我为一个主要国际客户工作的项目中的一个例子。客户聘请了我工作的公司和另一个开发团队来处理一些微服务。由于时区差异,另一个团队在我们团队睡觉时提交他们的代码更改。

我们在一个早上到达时看到我们的测试结果仪表板亮起了红色。夜间的代码更改导致我们的大量测试失败。我们检查了其他团队的代码管道,惊讶地看到他们的所有测试都通过了。这毫无道理。我们的测试清楚地揭示出那个夜间代码提交中的缺陷。我们甚至可以从我们的测试失败中定位到这个缺陷。这个缺陷本应该在围绕该代码的单元测试中显现出来,但那些单元测试是通过的。原因?注释掉的断言。

另一个团队面临着交付的压力。他们遵守了当天将代码更改检查入库的指示。实际上,这些更改破坏了他们的单元测试。当他们没有足够的时间修复它们时,他们选择欺骗系统,将问题推迟到另一天。我不确定我是否应该责怪他们。有时候,100%的代码覆盖率和所有测试通过实际上什么也不意味着。

小心一开始就编写所有测试

TDD(测试驱动开发)的一个优势是它允许涌现式设计。我们进行一小部分设计工作,体现在一个测试中。然后我们进行下一小部分设计,体现在一个新的测试中。我们在进行过程中执行不同程度的重构。这样,我们了解我们的方法中哪些是有效的,哪些是不有效的。测试为我们提供了关于设计的快速反馈。

这种情况只会在我们一次写一个测试时发生。对于那些熟悉瀑布式项目方法的人来说,可能会倾向于将测试代码视为一个巨大的需求文档,在开发开始之前完成。虽然这比在文字处理器中简单地编写需求文档看起来更有希望,但它也意味着开发者无法从测试反馈中学习。没有反馈循环。这种测试方法应该避免。通过采取增量方法可以获得更好的结果。我们一次写一个测试,与生产代码一起编写,以确保测试通过。

写测试的第一步有助于持续交付

写测试的第一大好处可能在于持续交付的情况。持续交付依赖于高度自动化的管道。一旦代码更改推送到源代码控制,就会启动构建管道,运行所有测试,最后进行部署。

在这个系统中,代码无法部署的唯一原因——假设代码可以编译——是测试失败。这表明我们现有的自动化测试是必要且充分的,以创建所需级别的信心。

写测试的第一步不能保证这一点——我们可能仍然缺少测试——但与所有使用测试的方式相比,它可能是最有可能导致我们对每个关心的应用程序行为都有一个有意义的测试。

本节提出了编写测试先于生产代码以使它们通过,从而有助于增强我们对代码的信心以及有用的可执行规范的观点。然而,这并非编写代码的唯一方式。实际上,我们将看到的一种常见方法是先编写一大块代码,然后不久后编写测试。

下一节将探讨测试后方法的优缺点。

我们总是可以稍后进行测试,对吧?

在编写测试代码之前编写代码的另一种方法是先编写代码,然后编写测试。本节比较和对比了在代码之后编写测试与在代码之前编写测试。

编写测试的一种方法涉及编写代码块,然后为这些代码片段添加测试。这是一种在商业编程中使用的做法,其工作流程可以如下所示:

图 12.3 – 测试后工作流程

图 12.3 – 测试后工作流程

在选择一个用户故事进行开发后,会编写一个或多个生产代码片段。然后是测试!至少可以说,学术界对于测试后是否与测试优先有所不同的研究似乎存在分歧。从 2014 年 ACM 的一项研究中提取的结论如下:

…静态代码分析结果在 TDD 方面具有统计学上的显著性。此外,调查结果还显示,实验中的大多数开发者更喜欢 TLD 而不是 TDD,因为 TLD 的学习曲线要求较低。

(来源:dl.acm.org/doi/10.1145/2601248.2601267

然而,一位评论者指出,在这项研究中,以下情况适用:

…只有 13 位开发者中的 13 位提供了可用数据。这意味着统计分析是在使用 7 人组(TDD)和 6 人组(TLD)的群体中进行的。实验缺乏统计效力,结果并不确定的这一发现并不令人惊讶。

其他研究论文似乎也显示出类似乏力的结果。那么在实践中,我们应该从中吸取什么教训呢?让我们考虑一些测试后开发的实际细节。

测试后方法对 TDD 初学者来说更容易

研究的一个发现是,TDD 初学者发现测试后方法更容易上手。这似乎是合理的。在我们尝试 TDD 之前,我们可能会将编码和测试视为不同的活动。我们根据某些启发式方法编写代码,然后找出如何测试这些代码。采用测试后方法意味着编码阶段基本上不受测试需求的影响。我们可以像以前一样继续编码。我们不需要考虑测试对代码设计的影响。这种看似的优势是短暂的,因为我们发现需要添加测试访问点,但我们至少可以轻松地开始。

如果我们与生产代码同步编写测试,那么稍后添加测试可以合理地工作:编写一点代码,并为该代码编写一些测试——但不是为每个代码路径编写测试仍然是一个风险。

测试后使得测试每个代码路径变得更加困难

反对使用测试后方法的合理论点是,跟踪所有所需测试变得更加困难。表面上,这个说法可能并不完全正确。我们总能找到某种方法来跟踪所需的测试。无论何时编写,测试都是测试。

问题在于添加测试之间的时间增加。我们正在添加更多代码,这意味着在整个代码中添加更多执行路径。例如,我们编写的每个if语句代表两个执行路径。理想情况下,我们的代码中的每个执行路径都将有一个测试。我们添加的每个未测试的执行路径都使我们低于这个理想数量一个测试。这直接在流程图中展示:

图 12.4 – 展示执行路径

图 12.4 – 展示执行路径

此流程图描述了一个具有嵌套决策点(菱形形状)的过程,这导致三个可能的执行路径,标记为ABC。执行路径数量的技术度量称为圈复杂度。复杂度得分是根据代码中存在的线性独立执行路径数量计算出的数值。流程图中的代码的圈复杂度为三。

随着我们代码的圈复杂度增加,我们需要记住所有那些需要稍后编写的测试,这增加了我们的认知负荷。在某个时候,我们甚至可能发现自己定期停止编码,并写下关于稍后要添加哪些测试的笔记。这听起来像是简单地边编写测试边进行的更艰难版本。

当使用测试优先开发时,避免跟踪尚未编写的测试的问题。

测试后使得影响软件设计变得更加困难

测试优先开发的一个好处是反馈循环非常短。我们编写一个测试,然后完成一小部分生产代码。然后根据需要重构。这从瀑布式预计划设计转变为涌现设计。我们根据对正在解决的问题的更多了解来改变我们的设计,因为我们逐步解决更多的问题。

在编写了一块代码之后,再编写测试,就难以融入反馈。我们可能会发现我们创建的代码难以集成到其余代码库中。也许由于接口不明确,这段代码难以使用。鉴于我们为创建混乱的代码所付出的所有努力,我们可能会满足于这种尴尬的设计及其同样尴尬的测试代码。

测试后可能永远不会发生

开发通常是一项繁忙的活动,尤其是在涉及截止日期的情况下。时间压力可能意味着我们希望用来编写测试的时间根本就没有。项目经理对新的功能比对测试更感兴趣的情况并不少见。这似乎是一种错误的经济行为 – 因为用户只关心那些工作的功能 – 但这是开发者有时面临的一种压力。

本节已表明,在编写代码后不久编写测试可以与先编写测试一样有效,只要我们小心行事。这也似乎是一些开发者 TDD 之旅开始时的首选 – 但如果我们达到从不测试代码的极端,又会怎样呢?让我们快速回顾一下这种方法的后果。

测试?那是给那些不会写代码的人准备的!

本节讨论了自动化测试的另一个明显可能性 – 简单地不编写自动化测试,甚至可能完全不测试。这是可行的吗?

完全不测试是一个我们可以做出的选择,这可能不像听起来那么愚蠢。如果我们把测试定义为验证在目标环境中实现了某些结果,那么像深空探测器这样的东西在地球上是无法真正测试的。在测试过程中,我们最多只能模拟目标环境。大规模的 Web 应用程序很少能够用真实的负载配置文件进行测试。拿任何一个大型 Web 应用程序,向它投放一亿用户 – 所有人都在做无效的事情 – 看看大多数应用程序的表现如何。这可能不如开发者测试所建议的好。

在某些开发领域,我们可能会期望看到更少的自动化测试:

  • 数据迁移的 ETL提取、转换和加载脚本

ETL 脚本通常是单次事件,编写来解决一些特定数据迁移问题。为这些编写自动化测试并不总是值得,而是在类似的数据源集上执行手动验证。

  • 前端用户界面工作

根据编程方法的不同,编写前端代码的单元测试可能具有挑战性。无论我们采取什么方法,目前都无法自动化评估视觉外观和感觉。因此,通常会对用户界面的候选版本进行手动测试。

  • 基础设施即代码脚本

我们的应用程序需要部署到某个地方才能运行。最近的一种部署方法是使用像 Terraform 这样的语言通过代码来配置服务器。这是一个还不太容易自动化测试的领域。

那么当我们放弃测试自动化,甚至可能完全不测试时,实际上会发生什么呢?

如果我们在开发过程中不进行测试会发生什么?

我们可能会认为完全不测试是一个选择,但现实中,测试总会发生在某个时刻。我们可以用一个可能发生测试的时间线来展示这一点:

图 12.5 – 测试时间线

图 12.5 – 测试时间线

测试优先的方法将测试尽可能提前——称为左移的方法——这样缺陷可以廉价且容易地得到纠正。认为我们不会测试只会将测试推迟到用户开始使用功能之后。

最终,所有用户关心的代码最终都会被测试到。也许开发者没有测试它。也许测试会落到另一个专门的测试团队,他们会编写缺陷报告。也许缺陷会在软件运行过程中被发现。最常见的情况是,我们将测试外包给用户自己。

让用户为我们测试代码通常是一个坏主意。用户信任我们给他们提供解决他们问题的软件。无论我们的代码中的缺陷阻止了这种情况的发生,我们都会失去这种信任。信任的丧失损害了商业的 3R:收入、声誉和保留。用户可能会转向另一个供应商,其经过更好测试的代码实际上解决了用户的问题。

如果在我们发货之前测试我们的工作有任何可能性,我们应该抓住这个机会。我们越早将测试驱动的反馈循环融入我们的工作,就越容易提高该工作的质量。

在看过我们测试软件的何时之后,让我们转向哪里测试它。考虑到一个软件的整体设计,我们应该从哪里开始测试?下一节将回顾一种从设计的内部开始并逐步展开的测试方法。

从内部到外部测试

在本节中,我们将回顾我们选择 TDD 活动起点的选择。首先看的地方是我们软件系统内部,从细节开始。

当开始构建软件时,我们显然需要一个起点。一个起点是细节。软件由许多相互连接的组件组成,每个组件都执行整个任务的一部分。一些组件来自库代码。许多组件是定制制作的,以提供我们应用程序所需的功能。

那么,开始构建的一个地方是软件系统的内部。从一个整体用户故事开始,我们可以想象一个可能对我们有用的小型组件。我们可以从这个组件开始我们的 TDD 工作,看看这会带我们到何处。这是一种自下而上的设计方法,从较小的部分组成整体。

如果考虑我们 Wordz 应用程序结构的简化版本,我们可以如下说明从内到外的方法:

图 12.6 – 从内到外开发

图 12.6 – 从内到外开发

图表显示了突出显示的Score组件,这是我们打算使用从内到外的方法开始开发的地方。其他软件组件被灰色显示。我们还没有设计这些部分。我们会从一个测试开始,这个测试是为了我们希望Score组件具有的一些行为。我们会从这个起点向外工作。

这种自内而外的 TDD 风格也被称为经典 TDD芝加哥 TDD。它是由 Kent Beck 在他的书《通过示例进行测试驱动开发》中最初描述的方法。基本思想是从任何地方开始创建任何有用的代码构建块。然后我们开发一个逐渐增大的单元,它包含了早期的构建块。

自内而外的方法有几个优点:

  • 快速开始开发:在这种方法中,我们首先测试纯 Java 代码,使用 JUnit 和 AssertJ 等熟悉的工具。没有用户界面、Web 服务存根或数据库的设置。也没有用户界面测试工具的设置。我们直接跳入并使用 Java 编码。

  • 适合已知设计:随着经验的积累,我们认识到一些问题有已知的解决方案。也许我们之前写过类似的东西。也许我们知道一组有用的设计模式,这将有效。在这些情况下,从我们代码的内部结构开始是有意义的。

  • 与六边形架构良好配合:自内而外的 TDD 从内部六边形开始工作,即我们应用程序的领域模型。适配器层形成了一个自然的边界。自内而外的设计非常适合这种设计方法。

自然,没有什么是完美的,自内而外的 TDD 也不例外。一些挑战包括以下内容:

  • 浪费的可能性:我们开始自内而外的 TDD 时,基于对我们认为将需要的某些组件的最佳猜测。有时,后来发现我们可能不需要这些组件,或者我们应该在其他地方重构这些功能。我们的初始努力在某种程度上是浪费的——尽管它将帮助我们达到这个阶段。

  • 实施锁定风险:与上一个点相关,有时我们在对要解决的问题了解更多信息后,会从最初的设计中继续前进,但我们并不总是认识到沉没成本。我们总是有一种诱惑,即使我们之前写的组件不再那么合适,也要继续使用它,仅仅因为我们投入了时间和金钱去创建它。

自内而外的 TDD 是一种有用的方法,并由 Kent Beck 的书首次推广。然而,如果我们可以从内部开始,那么反过来呢?如果我们从系统的外部开始,然后逐步向内工作会怎样?下一节将回顾这种替代方法。

从外部进行测试

由于自内而外的 TDD 既有挑战也有优势,那么自外而内的 TDD 又有什么不同呢?本节回顾了从系统外部开始的不同方法。

自外而内的 TDD 从系统的外部用户开始。他们可能是人类用户或机器,消费我们软件提供的某些 API。这种 TDD 方法首先模拟一些外部输入,例如提交一个网页表单。

测试通常使用某种测试框架——例如 Selenium 或 Cypress 用于 Web 应用程序——允许测试调用特定的 Web 视图,模拟在字段中输入文本,然后点击提交按钮。然后我们可以以正常的方式使这个测试通过,只是这次我们将编写一些直接处理用户输入的代码。在我们的六边形架构模型中,我们将首先编写用户输入适配器。

我们可以这样说明自外向内的方法:

图 12.7 – 自外向内视图

图 12.7 – 自外向内视图

我们可以看到,一个名为Web API的组件是我们关注的焦点。我们将编写一个测试,设置足够的应用程序以运行处理 Web 请求的组件。测试将形成一个 Web 请求,将其发送到我们的软件,然后断言发送了正确的 Web 响应。测试还可以对软件本身进行测试,以验证其是否采取了预期的内部操作。我们从外部开始测试,随着开发的进行,我们逐渐向内部移动。

这种 TDD 方法在 Steve Freeman 和 Nat Pryce 的书籍《通过测试引导面向对象软件增长》中有描述。这种技术也被称为伦敦模拟主义的 TDD 学派。原因在于它最初流行的地点以及它使用模拟对象。为了测试驱动作为我们首先解决的问题的用户输入适配器,我们需要一个测试替身来代替软件的其余部分。模拟和存根是自外向内 TDD 的固有部分。

自外向内的 TDD,不出所料,有一些优点和缺点。让我们先看看优点:

  • 减少浪费:自外向内的 TDD 鼓励一种相当最小化的方法来满足外部行为。产生的代码往往高度定制,适用于当前的应用程序。相比之下,自内向外的 TDD 侧重于构建健壮的领域模型,可能提供比最终用户使用的更多功能。

  • 快速交付用户价值:因为我们从模拟用户请求的测试开始,所以我们编写的代码将满足用户请求。我们可以几乎立即向用户提供价值。

自外向内的 TDD 也有一些缺点,或者至少是局限性:

  • 最少抽象:相关地,在编写使测试通过所需的最少代码时,自外向内的测试驱动开发(TDD)可能会导致应用逻辑存在于适配器层。这可以在以后重构,但可能会导致代码库组织性较差。

  • 倒置的测试金字塔:如果我们所有的 TDD 测试努力都集中在外部响应上,那么实际上它们是端到端测试。这与推荐的测试金字塔模式相矛盾,该模式更倾向于在代码库内部使用更快的单元测试。只有较慢、可重复性较低的端到端测试可能会减慢开发速度。

两种传统的 TDD 学派在影响我们将产生的软件设计方面都提供了一定的优势。下一节将探讨六边形架构的影响。从我们将使用六边形方法的想法开始,我们可以结合两种 TDD 学派的优势。我们最终定义了自内而外和自外而内两种 TDD 方法之间的自然测试边界。

使用六边形架构定义测试边界

本节的主题是使用六边形架构如何影响 TDD。知道我们正在使用六边形架构为测试金字塔中的不同种类的测试提供了有用的边界。

从一个角度来看,我们如何组织代码库并不影响我们对 TDD 的使用。代码的内部结构只是一个实现细节,是许多可能中的一种,可以使我们的测试通过。尽管如此,有些代码结构比其他结构更容易使用。使用六边形架构作为基础结构确实为 TDD 提供了一些优势。原因在于端口和适配器的使用。

我们从前几章中学到,编写可以在代码运行的特定环境中控制的代码的测试更容易。我们看到了测试金字塔如何为不同种类的测试提供结构。使用端口和适配器方法为代码中的每种测试提供了清晰的边界。更好的是,它为我们提供了将更多测试带到单元测试级别的机会。

让我们回顾一下使用六边形架构编写的软件的每一层最适合哪些类型的测试。

自内而外的开发方法与领域模型配合良好

经典的测试驱动开发(TDD)采用自内而外的开发方法,其中我们选择一个特定的软件组件进行测试驱动。这个组件可能是一个单独的函数、一个单独的类,或者是一小群相互协作的类。我们使用 TDD 来测试这个组件作为一个整体,考虑到它提供给消费者的行为。

这种类型的组件位于领域模型中 – 内部六边形:

图 12.8 – 测试领域逻辑

图 12.8 – 测试领域逻辑

关键优势在于,这些组件易于编写测试,并且这些测试运行得非常快。所有内容都存在于计算机内存中,没有外部系统需要竞争。

另一个优势是,可以在这里以非常精细的粒度对复杂行为进行单元测试。一个例子是测试用于控制工作流程的有限状态机中的所有状态转换。

一个缺点是,如果发生大规模重构,这些细粒度的领域逻辑测试可能会丢失。如果在重构过程中移除了细粒度测试下的组件,其相应的测试将会丢失——但行为仍然会由于重构而存在于其他地方。重构工具无法做到的一件事是确定哪些测试代码与正在重构的生产代码相关联,并自动重构测试代码以适应新的结构。

从外部到内部的工作方式与适配器配合得很好

模拟风格 TDD 从外部到内部的角度进行开发。这对于我们的六边形架构中的适配器层来说是一个很好的匹配。我们可以假设核心应用程序逻辑位于领域模型中,并且已经通过快速单元测试在这里进行了测试。这留下了外部的六边形适配器,由集成测试进行测试。

这些集成测试只需要覆盖适配器提供的行为。这应该非常有限。适配器代码仅将外部系统使用的格式映射到领域模型所需的内容。它没有其他功能。

这种结构自然遵循测试金字塔指南。需要的集成测试较少。每个集成测试只测试一小部分行为:

图 12.9 – 测试适配器

图 12.9 – 测试适配器

这种测试风格独立验证适配器。它将需要一些端到端快乐路径测试来证明整个系统已经正确使用了正确的适配器。

用户故事可以在领域模型中进行测试

拥有一个包含所有应用程序逻辑的领域模型的好处是,我们可以测试完整用户故事的逻辑。我们可以用测试替身替换适配器来模拟外部系统的典型响应。然后我们可以使用 FIRST 单元测试来执行完整的用户故事:

图 12.10 – 测试用户故事

图 12.10 – 测试用户故事

优点是 FIRST 单元测试的速度和可重复性。在其他代码结构方法中,我们可能只能在一个测试环境中将用户故事作为端到端测试来执行,所有相关的缺点都会出现。能够在整个领域模型中测试用户故事逻辑——给我们一个高度信心,即我们的应用程序将满足用户的需求。

为了确保这种信心,我们需要适配器层的集成测试,以及一些跨选定用户故事的端到端测试,以确认整个应用程序的连接和配置是正确的。这些高级测试不需要像围绕领域模型进行的用户故事测试那样详细。

在领域模型周围有一个好的用户故事测试集,也使得在领域模型中进行大规模重构成为可能。我们可以有信心在广泛的用户故事测试指导下重构内部六边形。

本节向我们展示了如何将测试金字塔中的不同类型的测试与六边形架构的不同层联系起来。

摘要

本章讨论了我们可以编写测试的各个阶段——在我们编写代码之前,编写代码之后,或者甚至可能永远不编写。它提出了在编写代码之前编写测试的观点,认为这可以在有效执行路径覆盖和开发者便利性方面提供最大价值。我们继续回顾了六边形架构如何与 TDD 和测试金字塔相互作用,从而为将用户故事测试引入 FIRST 单元测试领域提供了机会。这允许快速且可重复地验证驱动我们用户故事的核心逻辑。

在下一章——以及本书的第三部分——我们将回到构建我们的 Wordz 应用程序。我们将充分利用我们迄今为止学到的所有技术。我们将从内部开始,从第十三章,“驱动领域层”开始。

问答

  1. 在代码编写后不久编写测试是否与测试优先的 TDD 一样好?

一些研究似乎表明,尽管在这个领域设置具有统计学意义的受控实验非常困难。我们可以考虑的一个因素是我们自己的个人纪律。如果我们晚些时候编写测试,我们是否确信我们会覆盖所有必要的部分?我个人得出结论,我不会记住所有需要覆盖的内容,并将需要做笔记。这些笔记可能最好以测试代码的形式捕捉,从而导致对测试驱动开发(TDD)优先编写测试的偏好。

  1. 六边形架构如何影响 TDD?

六边形架构在纯、内核领域逻辑和外部世界之间提供了一个清晰的分离。这使我们能够混合和匹配两种 TDD 学派,因为我们知道设计中有明确的边界,我们可以在此范围内进行编码。内部领域模型支持整个用例进行单元测试,以及任何我们认为必要的详细行为的细粒度单元测试。外部适配器自然适合集成测试,但这些测试不必覆盖太多,因为与我们的领域逻辑相关的逻辑生活在内部领域模型中。

  1. 如果我们完全放弃测试,会发生什么?

我们将责任出口给最终用户,他们将为我们进行测试。我们面临收入、声誉和用户保留的损失风险。有时,我们无法完美地复制系统将使用的最终环境。在这种情况下,确保我们尽可能全面地描述和测试我们的系统似乎是明智的。我们至少可以最小化已知的风险。

进一步阅读

  • 对循环复杂度指标的说明:en.wikipedia.org/wiki/Cyclomatic_complexity

  • 《持续交付》,作者:Jez Humble 和 Dave Farley,ISBN 978-0321601919

  • 《与遗留代码有效协作》,作者:Michael Feathers,ISBN 978-0131177055

  • 通过示例进行测试驱动开发,Kent Beck 著,ISBN 978-0321146533

  • 面向对象软件增长,由测试引导,Steve Freeman 和 Nat Pryce 著,ISBN 9780321503626

  • arxiv.org/pdf/1611.05994.pdf

  • 为什么测试驱动开发的研究结果不一致?,Ghafari, Gucci, Gross 和 Felderer:arxiv.org/pdf/2007.09863.pdf

第三部分:现实世界的 TDD

第三部分是我们应用所学所有技术来完成应用程序的地方。Wordz 是一个猜词游戏网络服务。我们基于已经构建的核心领域逻辑进行构建,通过 SQL 访问 Postgres 数据库添加存储,并通过实现 HTTP REST API 提供网络访问。

我们将使用集成测试来测试驱动我们的数据库和 API 实现,利用简化这些任务的测试框架。在本书的最后一章,我们将把所有内容整合起来,自信地运行我们的测试驱动 Wordz 应用程序。

本部分包含以下章节:

  • 第十三章驱动领域层

  • 第十四章驱动数据库层

  • 第十五章驱动网络层

第十三章:驱动领域层

在前面的章节中,我们做了大量的准备工作,涵盖了 TDD 技术和软件开发方法。现在我们可以应用这些能力来构建我们的 Wordz 游戏。我们将在本书中编写的有用代码的基础上构建,并致力于一个经过良好工程和测试的设计,使用测试优先的方法编写。

我们本章的目标是创建我们系统的领域层。我们将采用第九章中描述的六边形架构方法,六边形架构 – 解耦外部系统。领域模型将包含我们所有核心应用程序逻辑。此代码将不会绑定到任何外部系统技术(如 SQL 数据库或 Web 服务器)的细节。我们将为这些外部系统创建抽象,并使用测试替身来使我们能够测试驱动应用程序逻辑。

以这种方式使用六边形架构允许我们为完整用户故事编写 FIRST 单元测试,这在其他设计方法中通常需要集成或端到端测试。我们将通过应用书中至今为止提出的思想来编写我们的领域模型代码。

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

  • 开始新游戏

  • 玩游戏

  • 结束游戏

技术要求

本章的最终代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter13找到。

开始新游戏

在本节中,我们将通过编写游戏代码来开始。像每个项目一样,开始通常相当困难,第一个决定就是简单地确定从哪里开始。一个合理的方法是找到一个用户故事,这将开始充实代码的结构。一旦我们有一个合理的应用程序结构,就更容易确定新代码应该添加的位置。

在这种情况下,我们可以通过考虑开始新游戏时需要发生的事情来有一个良好的开始。这必须设置好以便开始游戏,因此将迫使做出一些关键的决定。

首先要处理的用户故事是开始一个新游戏:

  • 作为一名玩家,我想开始一个新游戏,以便我有一个新单词来猜测

当我们开始新游戏时,我们必须执行以下操作:

  1. 从可猜测的单词中随机选择一个单词

  2. 存储所选单词,以便可以计算猜测的分数

  3. 记录玩家现在可以做出一个初始猜测

在编写这个故事时,我们将假设使用六边形架构,这意味着任何外部系统都将由领域模型中的一个端口表示。考虑到这一点,我们可以创建我们的第一个测试,并从这里开始。

测试驱动开始新游戏

从一般方向来看,使用六边形架构意味着我们可以自由地使用 TDD 的外向内方法。无论我们为领域模型设计出什么,都不会涉及难以测试的外部系统。我们的单元测试确保是FIRST快速、隔离、可重复、自我检查及时

重要的是,我们可以编写覆盖用户故事所需整个逻辑的单元测试。如果我们编写的代码绑定到外部系统(例如,包含 SQL 语句并连接到数据库),我们需要一个集成测试来覆盖用户故事。我们选择六边形架构使我们免于这种限制。

在战术层面,我们将重用已经通过测试驱动开发的类,例如class WordSelectionclass Wordclass Score。只要有机会,我们将重用现有代码和第三方库。

我们的起点是编写一个测试来捕获我们与开始新游戏相关的设计决策:

  1. 我们将从名为NewGameTest的测试开始。这个测试将在领域模型中执行,以驱动出我们开始新游戏所需执行的所有操作:

    package com.wordz.domain;
    
    public class NewGameTest {
    
    }
    
  2. 对于这个测试,我们将首先编写 Act 步骤。我们假设使用六边形架构,因此 Act 步骤的设计目标是设计处理启动新游戏请求的端口。在六边形架构中,端口是允许某些外部系统与领域模型连接的代码片段。我们首先创建一个端口类:

    package com.wordz.domain;
    
    public class NewGameTest {
    
        void startsNewGame() {
    
            var game = new Game();
    
        }
    
    }
    

这里的关键设计决策是创建一个controller类来处理启动游戏的请求。它符合原始《设计模式》书籍中的控制器概念 – 一个将协调其他领域模型对象的领域模型对象。我们将让 IntelliJ IDE 创建空的Game类:

package com.wordz.domain;
public class Game {
}

这又是 TDD 的一个优点。当我们首先编写测试时,我们给 IDE 足够的信息来为我们生成样板代码。我们启用 IDE 自动完成功能以真正帮助我们。如果你的 IDE 在编写测试后不能自动生成代码,考虑升级你的 IDE。

  1. 下一步是在控制器类中添加一个start()方法来开始新游戏。我们需要知道我们要为哪个玩家开始游戏,因此我们传递一个Player对象。我们编写测试的 Act 步骤:

    public class NewGameTest {
    
        @Test
    
        void startsNewGame() {
    
            var game = new Game();
    
            var player = new Player();
    
            game.start(player);
    
        }
    
    }
    

我们允许 IDE 在控制器中生成方法:

public class Game {
    public void start(Player player) {
    }
}

跟踪游戏的进度

下一个设计决策涉及玩家开始新游戏预期的结果。需要记录以下两点:

  • 玩家尝试猜测的选定单词

  • 我们期望他们下一个猜测

选定的单词和当前尝试次数需要持久化存储。我们将使用存储库模式来抽象这一点。我们的存储库需要管理一些领域对象。这些对象将负责跟踪我们在游戏中的进度。

已经,我们可以看到 TDD 在快速设计反馈方面的好处。我们还没有写太多代码,但已经,看起来需要跟踪游戏进度的新的类最好被称为 class Game。然而,我们已经有了一个 class Game,负责启动新游戏。TDD 正在为我们提供设计反馈——我们的名称和责任不匹配。

我们必须选择以下选项之一来继续:

  • 保持现有的 class Game 不变。将这个新类命名为例如 ProgressAttempt

  • start() 方法更改为静态方法——一个适用于类所有实例的方法。

  • class Game 重命名为能更好地描述其责任的名字。然后,我们可以创建一个新的 class Game 来保存当前玩家的进度。

静态方法选项不太吸引人。在 Java 中使用面向对象编程时,静态方法似乎很少像创建另一个管理所有相关实例的新对象那样合适。在这个新对象上,静态方法变成了一个普通方法。使用 class Game 来表示游戏进度似乎会产生更具描述性的代码。让我们采用这种方法。

  1. 使用 IntelliJ IDEA IDE 对 class Gameclass Wordz 进行重构/重命名,它们代表进入我们的领域模型的入口点。我们还重命名了局部变量 game 以匹配:

    public class NewGameTest {
    
        @Test
    
        void startsNewGame() {
    
            var wordz = new Wordz();
    
            var player = new Player();
    
            wordz.start(player);
    
        }
    
    }
    

NewGameTest 测试的名称仍然很好。它代表了我们要测试的用户故事,并且与任何类名无关。IDE 也已对生产代码进行了重构:

public class Wordz {
    public void start(Player player) {
    }
}
  1. 使用 IDE 对 start() 方法进行重构/重命名为 newGame()。这在 Wordz 类的上下文中似乎更好地描述了方法的责任:

    public class NewGameTest {
    
        @Test
    
        void startsNewGame() {
    
            var wordz = new Wordz();
    
            var player = new Player();
    
            wordz.newGame(player);
    
        }
    
    }
    

class Wordz 的生产代码也将该方法重命名。

  1. 当我们开始新游戏时,我们需要选择一个要猜测的单词并开始玩家尝试的序列。这些事实需要存储在存储库中。让我们首先创建存储库。我们将它命名为 interface GameRepository 并在我们的测试中添加 Mockito 的 @Mock 支持:

    package com.wordz.domain;
    
    import org.junit.jupiter.api.Test;
    
    import org.junit.jupiter.api.extension.ExtendWith;
    
    import org.mockito.Mock;
    
    import org.mockito.junit.jupiter.MockitoExtension;
    
    @ExtendWith(MockitoExtension.class)
    
    public class NewGameTest {
    
        @Mock
    
        private GameRepository gameRepository;
    
        @InjectMocks
    
        private Wordz wordz;
    
        @Test
    
        void startsNewGame() {
    
            var player = new Player();
    
            wordz.newGame(player);
    
        }
    
    }
    

我们向类添加 @ExtendWith 注解以启用 Mockito 库自动为我们创建测试替身。我们添加了一个 gameRepository 字段,将其标注为 Mockito 的 @Mock。我们使用 Mockito 内置的 @InjectMocks 便利注解自动将这个依赖注入到 Wordz 构造函数中。

  1. 我们允许 IDE 为我们创建一个空接口:

    package com.wordz.domain;
    
    public interface GameRepository {
    
    }
    
  2. 在下一步中,我们将确认 gameRepository 是否被使用。我们决定在接口上添加一个 create() 方法,它只接受一个 class Game 对象实例作为其唯一参数。我们想要检查该对象实例的 class Game,因此添加了一个参数捕获器。这允许我们对该对象中包含的游戏数据进行断言:

    public class NewGameTest {
    
        @Mock
    
        private GameRepository gameRepository;
    
        @Test
    
        void startsNewGame() {
    
            var player = new Player();
    
            wordz.newGame(player);
    
            var gameArgument =
    
                   ArgumentCaptor.forClass(Game.class)
    
            verify(gameRepository)
    
               .create(gameArgument.capture());
    
            var game = gameArgument.getValue();
    
            assertThat(game.getWord()).isEqualTo("ARISE");
    
            assertThat(game.getAttemptNumber()).isZero();
    
            assertThat(game.getPlayer()).isSameAs(player);
    
        }
    
    }
    

一个好问题是为什么我们要断言那些特定的值。原因是当我们添加生产代码时,我们将采取欺骗手段,直到我们成功。我们将首先返回一个硬编码这些值的 Game 对象。然后我们可以分步骤工作。一旦欺骗版本使测试通过,我们可以完善测试并驱动代码以获取真正的单词。更小的步骤提供了更快的反馈。快速的反馈能够促进更好的决策。

关于在领域模型中使用获取器的注意事项

Game 类为它的每个私有字段都有 getXxx() 方法,在 Java 术语中被称为 获取器。这些方法打破了数据的封装。

这通常是不推荐的。它可能导致重要逻辑被放入其他类中——这是一种被称为“外来方法”的代码异味。面向对象编程的全部内容都是关于将逻辑和数据本地化,封装两者。获取器应该很少使用。但这并不意味着我们永远不会使用它们。

在这种情况下,class Game 的单一职责是将正在玩的游戏的当前状态传输到 GameRepository。实现这一点的最直接方式是为该类添加获取器。编写简单、清晰的代码胜过盲目遵循规则。

另一种合理的方法是在包级别可见性下添加一个 getXxx() 诊断方法,纯粹是为了测试。与团队确认这不应该成为公共 API 的一部分,并且在生产代码中不要使用它。正确地编写代码比过分关注设计细节更重要。

  1. 我们使用 IDE 为这些新的获取器创建空方法。下一步是运行 NewGameTest 并确认它失败:

图 13.1 – 我们失败的测试

图 13.1 – 我们失败的测试

  1. 这就足够我们编写一些更多的生产代码:

    package com.wordz.domain;
    
    public class Wordz {
    
        private final GameRepository gameRepository;
    
        public Wordz(GameRepository gr) {
    
            this.gameRepository = gr;
    
        }
    
        public void newGame(Player player) {
    
            var game = new Game(player, "ARISE", 0);
    
            gameRepository.create(game);
    
        }
    
    }
    

我们可以重新运行 NewGameTest 并观察它通过:

图 13.2 – 测试通过

图 13.2 – 测试通过

测试现在通过了。我们可以从我们的红-绿阶段过渡到考虑重构。立即跳出来的是测试中的 ArgumentCaptor 代码是多么难以阅读。它包含太多关于模拟机制细节的信息,而关于为什么我们使用这种技术的信息却不够详细。我们可以通过提取一个命名良好的方法来澄清这一点。

  1. 为了清晰起见,提取 getGameInRepository() 方法:

    @Test
    
    void startsNewGame() {
    
        var player = new Player();
    
        wordz.newGame(player);
    
        Game game = getGameInRepository();
    
        assertThat(game.getWord()).isEqualTo("ARISE");
    
        assertThat(game.getAttemptNumber()).isZero();
    
        assertThat(game.getPlayer()).isSameAs(player);
    
    }
    
    private Game getGameInRepository() {
    
        var gameArgument
    
           = ArgumentCaptor.forClass(Game.class)
    
        verify(gameRepository)
    
                .create(gameArgument.capture());
    
        return gameArgument.getValue();
    
    }
    

这使得测试更容易阅读,并可以看到其中的常规 Arrange、Act 和 Assert 模式。本质上这是一个简单的测试,应该这样阅读。现在我们可以重新运行测试,并确认它仍然通过。它确实通过了,我们满意地认为我们的重构没有破坏任何东西。

这完成了我们的第一个测试——干得好!我们在这里取得了良好的进展。看到测试通过总是让我感到很高兴,这种感觉永远不会过时。这个测试本质上是一个用户故事的端到端测试,仅作用于领域模型。使用六边形架构使我们能够编写覆盖我们应用程序逻辑细节的测试,同时避免测试环境的需求。因此,我们得到了运行更快、更稳定的测试。

在我们的下一个测试中还有更多工作要做,因为我们需要移除硬编码创建Game对象的过程。在下一节中,我们将通过三角化单词选择逻辑来解决这个问题。我们设计下一个测试来驱动随机选择单词的正确行为。

三角化单词选择

下一个任务是移除我们用来使上一个测试通过的不诚实行为。我们在创建Game对象时硬编码了一些数据。我们需要用正确的代码来替换它。这个代码必须从我们的已知五字母单词库中随机选择一个单词。

  1. 添加一个新的测试来驱动随机选择单词的行为:

        @Test
    
        void selectsRandomWord() {
    
        }
    
  2. 随机单词选择依赖于两个外部系统——包含可供选择的单词的数据库和随机数字的来源。由于我们使用六边形架构,领域层不能直接访问这些系统。我们将用两个接口来表示它们——这些系统的端口。对于这个测试,我们将使用Mockito来创建这些接口的存根:

    @ExtendWith(MockitoExtension.class)
    
    public class NewGameTest {
    
        @Mock
    
        private GameRepository gameRepository;
    
        @Mock
    
        private WordRepository wordRepository ;
    
        @Mock
    
        private RandomNumbers randomNumbers ;
    
        @InjectMocks
    
        private Wordz wordz;
    

这个测试向class Wordz引入了两个新的协作对象。这些是interface WordRepositoryinterface RandomNumbers的有效实现实例。我们需要将这些对象注入到Wordz对象中以便使用它们。

  1. 使用依赖注入,将两个新的接口对象注入到class Wordz构造函数中:

    public class Wordz {
    
        private final GameRepository gameRepository;
    
        private final WordSelection wordSelection ;
    
        public Wordz(GameRepository gr,
    
                     WordRepository wr,
    
                     RandomNumbers rn) {
    
            this.gameRepository = gr;
    
            this.wordSelection = new WordSelection(wr, rn);
    
        }
    

我们在构造函数中添加了两个参数。我们不需要直接将它们存储为字段。相反,我们使用之前创建的class WordSelection。我们创建一个WordSelection对象并将其存储在一个名为wordSelection的字段中。请注意,我们之前使用@InjectMocks的方式意味着我们的测试代码将自动将模拟对象传递给这个构造函数,而无需进一步修改代码。这非常方便。

  1. 我们设置了模拟。我们希望它们在调用interface WordRepositoryfetchWordByNumber()方法时模拟我们期望的行为,以及当调用interface RandomNumbersnext()方法时:

        @Test
    
        void selectsRandomWord() {
    
            when(randomNumbers.next(anyInt())).thenReturn(2);
    
            when(wordRepository.fetchWordByNumber(2))
    
                   .thenReturn("ABCDE");
    
        }
    

这将设置我们的模拟,以便当调用next()时,它将每次都返回单词编号2,作为将在完整应用程序中产生的随机数的测试替身。当使用2作为参数调用fetchWordByNumber()时,它将返回单词编号为2的单词,在我们的测试中将是"ABCDE"。查看那段代码,我们可以通过使用局部变量而不是那个魔法数字2来增加清晰度。对于代码的未来读者来说,随机数生成器输出和单词存储库之间的联系将更加明显:

    @Test
    void selectsRandomWord() {
        int wordNumber = 2;
        when(randomNumbers.next(anyInt()))
           .thenReturn(wordNumber);
        when(wordRepository
           .fetchWordByNumber(wordNumber))
               .thenReturn("ABCDE");
    }
  1. 再次看起来过于详细。对模拟机制的强调过多,而对模拟所代表的内容关注太少。让我们提取一个方法来解释为什么我们要设置这个存根。我们还将传入我们想要选择的单词。这将帮助我们更容易地理解测试代码的目的:

        @Test
    
        void selectsRandomWord() {
    
            givenWordToSelect("ABCDE");
    
        }
    
        private void givenWordToSelect(String wordToSelect){
    
            int wordNumber = 2;
    
            when(randomNumbers.next(anyInt()))
    
                    .thenReturn(wordNumber);
    
            when(wordRepository
    
                    .fetchWordByNumber(wordNumber))
    
                    .thenReturn(wordToSelect);
    
        }
    
  2. 现在,我们可以编写断言来确认这个单词被传递到gameRepositorycreate()方法 – 我们可以重用我们的getGameInRepository()断言辅助方法:

    @Test
    
    void selectsRandomWord() {
    
        givenWordToSelect("ABCDE");
    
        var player = new Player();
    
        wordz.newGame(player);
    
        Game game = getGameInRepository();
    
        assertThat(game.getWord()).isEqualTo("ABCDE");
    
    }
    

这与之前的测试startsNewGame采用相同的方法。

  1. 观察测试失败。编写生产代码使测试通过:

    public void newGame(Player player) {
    
        var word = wordSelection.chooseRandomWord();
    
        Game game = new Game(player, word, 0);
    
        gameRepository.create(game);
    
    }
    
  2. 观察新测试通过,然后运行所有测试:

图 13.3 – 原始测试失败

图 13.3 – 原始测试失败

我们最初的测试现在失败了。在我们最新的代码更改过程中,我们破坏了某些东西。TDD 通过为我们提供回归测试来保护我们。发生的事情是在移除原始测试所依赖的硬编码单词"ARISE"之后,它失败了。正确的解决方案是在我们的原始测试中添加所需的模拟设置。我们可以重用我们的givenWordToSelect()辅助方法来完成这项工作。

  1. 将模拟设置添加到原始测试中:

    @Test
    
    void startsNewGame() {
    
        var player = new Player();
    
        givenWordToSelect("ARISE");
    
        wordz.newGame(player);
    
        Game game = getGameInRepository();
    
        assertThat(game.getWord()).isEqualTo("ARISE");
    
        assertThat(game.getAttemptNumber()).isZero();
    
        assertThat(game.getPlayer()).isSameAs(player);
    
    }
    
  2. 重新运行所有测试并确认它们都通过:

图 13.4 – 所有测试通过

图 13.4 – 所有测试通过

我们已经通过测试驱动我们的第一段代码来启动新游戏,随机选择一个单词进行猜测,并使测试通过。在我们继续之前,是时候考虑我们应该重构什么了。我们一直在编写代码的同时整理代码,但有一个明显的特征。看看这两个测试。现在它们看起来非常相似。原始测试已经成为了我们用来测试驱动添加单词选择的测试的超集。selectsRandomWord()测试是一个Player变量:

  1. Player变量中提取一个常量:
private static final Player PLAYER = new Player();
@Test
void startsNewGame() {
    givenWordToSelect("ARISE");
    wordz.newGame(PLAYER);
    Game game = getGameInRepository();
    assertThat(game.getWord()).isEqualTo("ARISE");
    assertThat(game.getAttemptNumber()).isZero();
    assertThat(game.getPlayer()).isSameAs(PLAYER);
}
  1. 我们将在之后运行所有测试以确保它们仍然通过,并且selectsRandomWord()已经消失。

图 13.5 – 所有测试通过

图 13.5 – 所有测试通过

就这样!我们已经通过测试驱动了启动游戏所需的所有行为。这是一个重大的成就,因为该测试覆盖了一个完整的故事。所有领域逻辑都已测试,并且已知其工作正常。设计看起来很简单。测试代码是我们期望代码执行的操作的明确规范。这是巨大的进步。

在这次重构之后,我们可以继续进行下一个开发任务——支持游戏玩法的代码。

进行游戏

在本节中,我们将构建游戏的逻辑。游戏玩法包括对所选单词进行多次猜测,查看该猜测的得分,并进行另一次猜测。游戏在单词被正确猜中或达到允许尝试的最大次数时结束。

我们首先假设我们处于典型游戏的开头,即将进行第一次猜测。我们还将假设这次猜测并不完全正确。这允许我们推迟关于游戏结束行为的决策,这是好事,因为我们已经有足够的事情要决定了。

设计评分界面

我们必须做出的第一个设计决策是在对单词进行猜测后需要返回什么。我们需要向用户返回以下信息:

  • 当前猜测的得分

  • 游戏是否仍在进行或已结束

  • 可能是每次猜测的评分历史

  • 可能是用户输入错误的报告

显然,对玩家来说最重要的信息是当前猜测的得分。没有这个信息,游戏就无法进行。由于游戏长度可变——当单词被猜中或尝试猜测的最大次数达到时结束——我们需要一个指示器来表示是否允许进行另一次猜测。

返回之前猜测的得分历史背后的想法是,这可能会帮助我们的领域模型消费者——最终,某种类型用户界面。如果我们只返回当前猜测的得分,用户界面很可能需要保留自己的得分历史,以便正确展示。如果我们返回整个游戏的得分历史,该信息将很容易获得。在软件中,一个好的经验法则是遵循你不需要它YAGNI)原则。由于没有对得分历史的需要,我们不会在这个阶段构建它。

我们需要做出的最后一个决策是思考我们想要的编程接口。我们将在class Wordz上选择一个assess()方法。它将接受String,即玩家当前猜测的字符串。它将返回record,这是现代 Java(自 Java 14 起)表示纯数据结构应返回的一种方式:

我们现在已经有了足够的内容来编写测试。我们将为所有与猜测相关的行为创建一个新的测试,称为GuessTest。测试看起来像这样:

@ExtendWith(MockitoExtension.class)
public class GuessTest {
    private static final Player PLAYER = new Player();
    private static final String CORRECT_WORD = "ARISE";
    private static final String WRONG_WORD = "RXXXX";
    @Mock
    private GameRepository gameRepository;
    @InjectMocks
    private Wordz wordz;
    @Test
    void returnsScoreForGuess() {
        givenGameInRepository(
                       Game.create(PLAYER, CORRECT_WORD));
        GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
        Letter firstLetter = result.score().letter(0);
        assertThat(firstLetter)
               .isEqualTo(Letter.PART_CORRECT);
    }
    private void givenGameInRepository(Game game) {
        when(gameRepository
           .fetchForPlayer(eq(PLAYER)))
              .thenReturn(Optional.of(game));
    }
}

测试中没有新的 TDD 技术。它驱动了我们的新assess()方法的调用接口。我们使用了静态构造器习语,通过Game.create()创建游戏对象。这个方法已经被添加到class Game中:

    static Game create(Player player, String correctWord) {
        return new Game(player, correctWord, 0, false);
    }

这澄清了创建新游戏所需的信息。为了使测试能够编译,我们创建record GuessResult

package com.wordz.domain;
import java.util.List;
public record GuessResult(
        Score score,
        boolean isGameOver
) { }

我们可以通过在class Wordz中编写assess()方法的产物代码来使测试通过。为了做到这一点,我们将重用我们已经编写的class Word类:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    var target = new Word(game.getWord());
    var score = target.guess(guess);
    return new GuessResult(score, false);
}

断言只检查第一个字母的分数是否正确。这是一个故意设计的弱测试。对评分行为的详细测试是在我们之前编写的class WordTest中完成的。这个测试被描述为弱测试,因为它并没有完全测试返回的分数,只是测试了它的第一个字母。评分逻辑的强测试发生在其他地方,在class WordTest中。这里的弱测试确认我们至少有一种能够正确评分一个字母的能力,这对于我们测试驱动产品代码来说是足够的。我们避免在这里重复测试。

运行测试显示它通过了。我们可以审查测试代码和产品代码,看看重构是否会改善它们的设计。到目前为止,没有什么需要我们紧急关注的。我们可以继续通过游戏跟踪进度。

三角测量游戏进度跟踪

我们需要跟踪已经做出的猜测次数,以便在尝试次数达到最大值后结束游戏。我们的设计选择是更新Game对象中的attemptNumber字段,然后将其存储在GameRepository中:

  1. 我们添加了一个测试来驱动这段代码:

    @Test
    
    void updatesAttemptNumber() {
    
        givenGameInRepository(
    
                   Game.create(PLAYER, CORRECT_WORD));
    
        wordz.assess(PLAYER, WRONG_WORD);
    
        var game = getUpdatedGameInRepository();
    
        assertThat(game.getAttemptNumber()).isEqualTo(1);
    
    }
    
    private Game getUpdatedGameInRepository() {
    
        ArgumentCaptor<Game> argument
    
                = ArgumentCaptor.forClass(Game.class);
    
        verify(gameRepository).update(argument.capture());
    
        return argument.getValue();
    
    }
    

这个测试在我们的interface GameRepository中引入了一个新方法update(),负责将最新的游戏信息写入存储。断言步骤使用 Mockito 的ArgumentCaptor来检查我们传递给update()方法的Game对象。我们已经编写了一个getUpdatedGameInRepository()方法来淡化我们检查传递给gameRepository.update()方法的内容的内部工作原理。测试中的assertThat()验证attemptNumber已被增加。由于我们创建了一个新游戏,它从零开始,因此预期的新的值是1。这是跟踪猜测单词尝试的期望行为:

  1. 我们向GameRepository接口添加了update()方法:

    package com.wordz.domain;
    
    public interface GameRepository {
    
        void create(Game game);
    
        Game fetchForPlayer(Player player);
    
        void update(Game game);
    
    }
    
  2. 我们在class Wordz中的assess()方法中添加了产物代码来增加attemptNumber并调用update()

    public GuessResult assess(Player player, String guess) {
    
        var game = gameRepository.fetchForPlayer(player);
    
        game.incrementAttemptNumber();
    
        gameRepository.update(game);
    
        var target = new Word(game.getWord());
    
        var score = target.guess(guess);
    
        return new GuessResult(score, false);
    
    }
    
  3. 我们向class Game添加了incrementAttemptNumber()方法:

    public void incrementAttemptNumber() {
    
        attemptNumber++;
    
    }
    

测试现在通过了。我们可以考虑任何我们想要进行的重构改进。有两件事似乎很突出:

  • class NewGameTestclass GuessTest之间重复的测试设置。

在这个阶段,我们可以容忍这种重复。选项包括将两个测试合并到同一个测试类中,扩展一个公共的测试基类,或者使用组合。它们似乎都不太可能对可读性有很大帮助。现在将两个不同的测试用例分开似乎相当不错。

  • assess()方法内部的这三行必须在我们尝试另一次猜测时作为一个单元始终被调用。有可能忘记调用其中之一,因此似乎更好的做法是重构以消除这种可能错误。我们可以这样重构:

    public GuessResult assess(Player player, String guess) {
    
        var game = gameRepository.fetchForPlayer(player);
    
        Score score = game.attempt( guess );
    
        gameRepository.update(game);
    
        return new GuessResult(score, false);
    
    }
    

我们将之前在这里的代码移动到新创建的方法:class Game中的attempt()

public Score attempt(String latestGuess) {
    attemptNumber++;
    var target = new Word(targetWord);
    return target.guess(latestGuess);
}

将方法参数从guess重命名为latestGuess提高了可读性。

这样就完成了猜测单词所需的代码。让我们继续测试驱动检测游戏何时结束所需的代码。

结束游戏

在本节中,我们将完成驱动检测游戏结束所需的测试和生产代码。这将在我们执行以下任一操作时发生:

  • 正确猜测单词

  • 根据最大次数进行最后的允许尝试

我们可以通过编写在正确猜测单词时检测游戏结束的代码来开始。

对正确猜测做出响应

在这种情况下,玩家正确猜出了目标单词。游戏结束,玩家根据在正确猜测之前所需的尝试次数获得一定数量的分数。我们需要传达游戏已经结束以及获得了多少分数,这导致在我们的class GuessResult中出现了两个新的字段。我们可以在现有的class GuessTest中添加一个测试,如下所示:

@Test
void reportsGameOverOnCorrectGuess(){
    var player = new Player();
    Game game = new Game(player, "ARISE", 0);
    when(gameRepository.fetchForPlayer(player))
                          .thenReturn(game);
    var wordz = new Wordz(gameRepository,
                           wordRepository, randomNumbers);
    var guess = "ARISE";
    GuessResult result = wordz.assess(player, guess);
    assertThat(result.isGameOver()).isTrue();
}

这驱动出class GuessResult中的一个新的isGameOver()访问器和使其为true的行为:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    Score score = game.attempt( guess );
    if (score.allCorrect()) {
        return new GuessResult(score, true);
    }
    gameRepository.update(game);
    return new GuessResult(score, false);
}

这本身在class WordTest中驱动出两个新的测试:

@Test
void reportsAllCorrect() {
    var word = new Word("ARISE");
    var score = word.guess("ARISE");
    assertThat(score.allCorrect()).isTrue();
}
@Test
void reportsNotAllCorrect() {
    var word = new Word("ARISE");
    var score = word.guess("ARI*E");
    assertThat(score.allCorrect()).isFalse();
}

这些本身就在class Score中驱动出一个实现:

public boolean allCorrect() {
    var totalCorrect = results.stream()
            .filter(letter -> letter == Letter.CORRECT)
            .count();
    return totalCorrect == results.size();
}

通过这种方式,我们为record GuessResult中的isGameOver访问器提供了一个有效的实现。所有测试都通过了。看起来似乎不需要重构。我们将继续进行下一个测试。

由于猜测错误过多而导致游戏结束

下一个测试将驱动超出游戏允许的最大猜测次数时的响应:

@Test
void gameOverOnTooManyIncorrectGuesses(){
    int maximumGuesses = 5;
    givenGameInRepository(
            Game.create(PLAYER, CORRECT_WORD,
                    maximumGuesses-1));
    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
    assertThat(result.isGameOver()).isTrue();
}

这个测试设置gameRepository以允许一次,最后的猜测。然后设置猜测为不正确。我们断言在这种情况下isGameOver()true。测试最初失败,正如预期的那样。我们在class Game中添加了一个额外的静态构造方法来指定初始尝试次数。

我们添加了基于最大猜测次数结束游戏的生产代码:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    Score score = game.attempt( guess );
    if (score.allCorrect()) {
        return new GuessResult(score, true);
    }
    gameRepository.update(game);
    return new GuessResult(score,
                           game.hasNoRemainingGuesses());
}

我们将这个决策支持方法添加到class Game中:

public boolean hasNoRemainingGuesses() {
    return attemptNumber == MAXIMUM_NUMBER_ALLOWED_GUESSES;
}

我们的所有测试现在都通过了。然而,代码中有些可疑之处。它已经被非常精细地调整,只有在猜测正确且在允许的猜测次数内,或者猜测错误且正好在允许的次数时才会工作。是时候添加一些边界条件测试并双重检查我们的逻辑了。

游戏结束后对猜测的响应三角化

我们需要在游戏结束检测的边界条件周围进行更多测试。第一个测试是针对在正确猜测之后提交错误猜测的响应:

@Test
void rejectsGuessAfterGameOver(){
    var gameOver = new Game(PLAYER, CORRECT_WORD,
                1, true);
    givenGameInRepository( gameOver );
    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
    assertThat(result.isError()).isTrue();
}

这个测试中包含了一些设计决策:

  • 一旦游戏结束,我们就在class Game中的新字段isGameOver中记录这一点。

  • 这个新字段将在游戏结束时需要被设置。我们需要更多的测试来驱动这种行为。

  • 我们将使用一个简单的错误报告机制——在class GuessResult中添加一个新字段isError

这导致了一些自动重构,以向class Game构造函数添加第四个参数。然后,我们可以添加代码来使测试通过:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    if(game.isGameOver()) {
        return GuessResult.ERROR;
    }
    Score score = game.attempt( guess );
    if (score.allCorrect()) {
        return new GuessResult(score, true, false);
    }
    gameRepository.update(game);
    return new GuessResult(score,
                   game.hasNoRemainingGuesses(), false);
}

这里的设计决策是,一旦我们获取到Game对象,我们就检查游戏是否之前被标记为结束。如果是这样,我们就报告一个错误,然后结束。这很简单、很粗糙,但对我们来说足够了。我们还添加了一个静态常量GuessResult.ERROR以提高可读性:

    public static final GuessResult ERROR
                  = new GuessResult(null, true, true);

这一设计决策的一个后果是,每当Game.isGameOver字段变为true时,我们必须更新GameRepository。这些测试中的一个例子如下:

@Test
void recordsGameOverOnCorrectGuess(){
    givenGameInRepository(Game.create(PLAYER, CORRECT_WORD));
    wordz.assess(PLAYER, CORRECT_WORD);
    Game game = getUpdatedGameInRepository();
    assertThat(game.isGameOver()).isTrue();
}

这里是添加记录逻辑的生产代码:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    if(game.isGameOver()) {
        return GuessResult.ERROR;
    }
    Score score = game.attempt( guess );
    if (score.allCorrect()) {
        game.end();
        gameRepository.update(game);
        return new GuessResult(score, true, false);
    }
    gameRepository.update(game);
    return new GuessResult(score,
                 game.hasNoRemainingGuesses(), false);
}

我们需要另一个测试来驱动当我们用完猜测时记录游戏结束。这将导致生产代码的变化。这些变化可以在本章开头给出的链接在 GitHub 上找到。它们与之前所做的非常相似。

最后,让我们回顾我们的设计,看看我们是否还能进一步改进它。

审查我们的设计

我们在编写代码的同时,一直在进行小的、战术性的重构步骤,这始终是一个好主意。就像园艺一样,如果我们能在杂草生长之前拔掉它们,保持花园整洁就更容易了。即便如此,在我们继续前进之前,审视一下我们代码和测试的设计是值得的。我们可能再也没有机会触摸这段代码了,而且它上面有我们的名字。让我们让它成为我们引以为豪的东西,并且在未来让我们的同事能够安全简单地与之合作。

我们已经编写的测试使我们能够在重构时有很大的灵活性。它们避免了测试特定的实现,而是测试了期望的结果。它们还测试了更大的代码单元——在这种情况下,我们的六边形架构的领域模型。因此,在不更改任何测试的情况下,我们可以重构我们的class Wordz,使其看起来像这样:

package com.wordz.domain;
public class Wordz {
    private final GameRepository gameRepository;
    private final WordSelection selection ;
    public Wordz(GameRepository repository,
                 WordRepository wordRepository,
                 RandomNumbers randomNumbers) {
        this.gameRepository = repository;
        this.selection =
             new WordSelection(wordRepository, randomNumbers);
    }
    public void newGame(Player player) {
        var word = wordSelection.chooseRandomWord();
        gameRepository.create(Game.create(player, word));
    }

我们重构后的assess()方法现在看起来是这样的:

    public GuessResult assess(Player player, String guess) {
        Game game = gameRepository.fetchForPlayer(player);
        if(game.isGameOver()) {
            return GuessResult.ERROR;
        }
        Score score = game.attempt( guess );
        gameRepository.update(game);
        return new GuessResult(score,
                               game.isGameOver(), false);
    }
}

这看起来更简单了。现在class GuessResult构造函数的代码显得特别丑陋。它具有使用多个布尔标志值的经典反模式。我们需要明确不同的组合实际上意味着什么,以简化对象的创建。一个有用的方法是一次再次应用静态构造函数习惯用法:

package com.wordz.domain;
public record GuessResult(
        Score score,
        boolean isGameOver,
        boolean isError
) {
    static final GuessResult ERROR
         = new GuessResult(null, true, true);
    static GuessResult create(Score score,
                              boolean isGameOver) {
        return new GuessResult(score, isGameOver, false);
    }
}

这简化了 assess() 方法,消除了理解最终布尔标志的需要:

public GuessResult assess(Player player, String guess) {
    Game game = gameRepository.fetchForPlayer(player);
    if(game.isGameOver()) {
        return GuessResult.ERROR;
    }
    Score score = game.attempt( guess );
    gameRepository.update(game);
    return GuessResult.create(score, game.isGameOver());
}

另一项改进是为了帮助理解,涉及创建 class Game 的新实例。rejectsGuessAfterGameOver() 测试使用四个参数构造函数中的布尔标志值来设置测试在游戏结束状态。让我们使创建游戏结束状态的目标明确化。我们可以将 Game 构造函数设为私有,并提高 end() 方法的可见性,该方法已经被用来结束游戏。我们的修订测试看起来是这样的:

@Test
void rejectsGuessAfterGameOver(){
    var game = Game.create(PLAYER, CORRECT_WORD);
    game.end();
    givenGameInRepository( game );
    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
    assertThat(result.isError()).isTrue();
}

安排步骤现在更具描述性。四个参数的构造函数不再可访问,引导未来的开发使用更安全、更具描述性的静态构造函数方法。这种改进的设计有助于防止未来引入缺陷。

我们在本章中取得了巨大的进步。在完成这些最终的重构改进之后,我们得到了一个易于阅读的游戏核心逻辑描述。它完全由 FIRST 单元测试支持。我们甚至实现了对测试执行代码行的有意义的 100% 代码覆盖率。这可以在 IntelliJ 代码覆盖率工具中看到:

图 13.6 – 代码覆盖率报告

图 13.6 – 代码覆盖率报告

这样,我们游戏的核心部分就完成了。我们可以开始新游戏,玩游戏,结束游戏。游戏可以进一步开发,包括基于单词猜测速度的得分奖励和玩家高分榜等功能。这些功能将使用我们在本章中一直应用的技术添加。

摘要

在本章中,我们覆盖了大量的内容。我们使用了 TDD 来驱动 Wordz 游戏的核心应用逻辑。我们采取了小步骤,并使用三角测量法稳步地将更多细节引入到我们的代码实现中。我们使用了六边形架构,使我们能够使用 FIRST 单元测试,从而摆脱了繁琐的集成测试及其测试环境。我们使用了测试替身来替换难以控制的对象,例如数据库和随机数生成。

我们建立了一套宝贵的单元测试套件,这些测试与特定的实现解耦。这使得我们能够自由地重构代码,最终得到一个非常优秀的软件设计,基于 SOLID 原则,这将显著减少维护工作量。

我们以一个有意义的代码覆盖率报告结束,该报告显示生产代码的 100% 行都被我们的测试执行,这让我们对我们的工作有了很高的信心。

接下来,在第十四章 驱动数据库层中,我们将编写数据库适配器以及一个集成测试来实现我们的 GameRepository,使用的是 Postgres 数据库。

问答

  1. 每个类中的每个方法都必须有自己的单元测试吗?

不。这似乎是一个常见的观点,但这是有害的。如果我们采用这种方法,我们将锁定实现细节,并且无法在不破坏测试的情况下进行重构。

  1. 在运行测试时,100%的代码覆盖率有什么意义?

本身并不多。它仅仅意味着在测试运行期间,测试单元中的所有代码行都已被执行。对于我们来说,由于我们使用的是测试驱动开发(TDD),这意味着更多。我们知道每一行代码都是由一个对我们应用程序重要的行为测试所驱动的。拥有 100%的覆盖率是一个双重检查,确保我们没有忘记添加测试。

  1. 测试运行期间 100%的代码覆盖率是否意味着我们拥有完美的代码?

不。测试只能揭示缺陷的存在,而不能揭示它们的缺失。在可读性和边缘情况处理方面,我们可以有 100%的覆盖率,但代码质量可能很低。不要过分重视代码覆盖率指标。对于 TDD 来说,它们作为交叉检查,确保我们没有遗漏任何边界条件测试。

  1. 所有这些重构是否正常?

是的。测试驱动开发(TDD)全部关于快速反馈循环。反馈帮助我们探索设计想法,并在我们发现更好的设计时改变主意。它使我们免于在开始工作之前必须理解每个细节的暴政。我们通过做工作来发现设计,并在结束时展示出有实际工作的软件。

进一步阅读

  • AssertJ 文档 – 了解更多关于 AssertJ 内置的各种断言匹配器,以及如何创建自定义断言的详细信息,请参阅:assertj.github.io/doc/

  • 重构 – 改进现有代码的设计,马丁·福勒(第一版),ISBN 9780201485677:

在测试驱动开发(TDD)中,我们的大部分工作是对代码进行重构,持续提供足够好的设计以支持我们的新功能。这本书提供了关于如何以纪律性、分步骤的方式进行重构的极好建议。

该书的初版使用 Java 作为所有示例,因此对我们来说比基于 JavaScript 的第二版更有用。

  • 设计模式 – 可复用面向对象软件元素,Gamma, Helm, Vlissides, Johnson,ISBN 9780201633610:

一本里程碑式的书籍,记录了在面向对象软件中常见的类组合。在章节的早期,我们使用了控制器类。这本书将其描述为外观模式。列出的模式不包含任何类型的框架或软件层,因此在构建六边形架构的领域模型时非常有用。

第十四章:驱动数据库层

在本章中,我们将实现领域模型中的一个端口的数据库适配器,该端口由WordRepository接口表示。这将允许我们的领域模型从真实数据库(在这种情况下,使用流行的开源数据库Postgres)中检索猜测单词。我们将驱动数据库设置和访问数据库的代码的测试驱动开发。为了帮助我们做到这一点,我们将使用一个旨在简化编写数据库集成测试的测试框架,称为DBRider

到本章结束时,我们将编写一个针对运行中的数据库的集成测试,实现WordRepository接口中的fetchesWordByNumber()方法,并使用JDBI数据库访问库来帮助我们。我们将创建一个具有对存储猜测单词的表权限的数据库用户。我们将创建该表,然后编写 JDBI 将用于检索我们正在寻找的单词的 SQL 查询。我们将使用命名参数 SQL 查询来避免由 SQL 注入引起的一些应用程序安全问题。

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

  • 创建数据库集成测试

  • 实现单词存储库适配器

技术要求

本章的最终代码可以在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter14找到。

安装 Postgres 数据库

在本章中,我们将使用 Postgres 数据库,这需要安装。要安装 Postgres,请按照以下步骤操作:

  1. 访问以下网页:www.postgresql.org/download/

  2. 按照您操作系统的安装说明进行操作。

代码已与 14.5 版本进行测试。预计将在所有版本上工作。

设置完成后,让我们开始实现数据库代码。在下一节中,我们将使用 DBRider 框架创建数据库集成测试。

创建数据库集成测试

在本节中,我们将使用名为 DBRider 的测试框架创建数据库集成测试的框架。我们将使用这个测试来驱动数据库表和数据库用户的创建。我们将致力于实现WordRepository接口,该接口将访问存储在 Postgres 数据库中的单词。

在此之前,我们为 Wordz 应用程序创建了一个领域模型,使用六边形架构来指导我们。我们的领域模型不是直接访问数据库,而是使用一个名为WordRepository接口的抽象,它代表用于猜测的存储单词。

在六边形架构中,端口必须始终由适配器实现。WordRepository接口的适配器将是一个实现接口的类,包含访问真实数据库所需的所有代码。

为了测试驱动此适配器代码,我们将编写一个集成测试,使用支持测试数据库的库。这个库叫做 DBRider,它是项目gradle.build文件中列出的依赖之一:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.mockito:mockito-core:4.8.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
testImplementation 'com.github.database-rider:rider-core:1.33.0'
    testImplementation 'com.github.database-rider:rider-junit5:1.33.0'
    implementation 'org.postgresql:postgresql:42.5.0'
}

DBRider 有一个配套的库叫做rider-junit5,它与JUnit5集成。有了这个新的测试工具,我们可以开始编写测试。首先要做的是设置测试,使其使用 DBRider 连接到我们的 Postgres 数据库。

使用 DBRider 创建数据库测试

在我们测试驱动任何应用程序代码之前,我们需要一个连接到我们的 Postgres 数据库、在本地上运行的测试。我们以通常的方式开始,编写一个 JUnit5 测试类:

  1. 在新com.wordz.adapters.db包的/test/目录中创建一个新的测试类文件:

图 14.1 – 集成测试

图 14.1 – 集成测试

IDE 将为我们生成空测试类。

  1. @DBRider@DBUnit注解添加到测试类中:

    @DBRider
    
    @DBUnit(caseSensitiveTableNames = true,
    
            caseInsensitiveStrategy= Orthography.LOWERCASE)
    
    public class WordRepositoryPostgresTest {
    
    }
    

@DBUnit 注解中的参数减轻了 Postgres 和 DBRider 测试框架之间的一些奇怪交互,这些交互与表和列名的大小写敏感性有关。

  1. 我们想测试能否检索一个单词。添加一个空测试方法:

        @Test
    
        void fetchesWord()  {
    
        }
    
  2. 运行测试。它将失败:

图 14.2 – DBRider 无法连接到数据库

图 14.2 – DBRider 无法连接到数据库

  1. 修复此问题的下一步是遵循 DBRider 文档并添加将被 DBRider 框架使用的代码。我们添加一个connectionHolder字段和一个javax.sqlDataSource字段来支持它:

    @DBRider
    
    public class WordRepositoryPostgresTest {
    
        private DataSource dataSource;
    
        private final ConnectionHolder connectionHolder
    
                    = () -> dataSource.getConnection();
    
    }
    

dataSource是创建与我们的 Postgres 数据库连接的标准JDBC方式。我们运行测试。它以不同的错误消息失败:

图 14.3 – dataSource 为空

图 14.3 – dataSource 为空

  1. 我们通过添加一个@BeforeEach方法来设置dataSource来纠正这个问题:

        @BeforeEach
    
        void setupConnection() {
    
            var ds = new PGSimpleDataSource();
    
            ds.setServerNames(new String[]{"localhost"});
    
            ds.setDatabaseName("wordzdb");
    
            ds.setCurrentSchema("public");
    
            ds.setUser("ciuser");
    
            ds.setPassword("cipassword");
    
            this.dataSource = ds;
    
        }
    

这指定了我们想要一个名为ciuser、密码为cipassword的用户连接到名为wordzdb的数据库,该数据库在localhost上以 Postgres 的默认端口(5432)运行。

  1. 运行测试并查看它失败:

图 14.4 – 用户不存在

图 14.4 – 用户不存在

错误产生的原因是我们还没有在我们的 Postgres 数据库中知道名为ciuser的用户。让我们创建一个。

  1. 打开psql终端并创建用户:

    create user ciuser with password 'cipassword';
    
  2. 再次运行测试:

图 14.5 – 数据库未找到

图 14.5 – 数据库未找到

测试失败,因为 DBRider 框架正在尝试将我们的新ciuser用户连接到wordzdb数据库。此数据库不存在。

  1. psql终端中创建数据库:

    create database wordzdb;
    
  2. 再次运行测试:

图 14.6 – 测试通过

图 14.6 – 测试通过

fetchesWord() 测试现在通过了。我们回忆起测试方法本身是空的,但这意味着我们已经有了足够的数据库设置来继续进行测试驱动开发代码。我们很快就会回到数据库设置,但我们将让测试驱动引导我们。下一项任务是向 fetchesWord() 测试中添加缺失的 Arrange、Act 和 Assert 代码。

驱动生产代码

我们的目标是通过测试驱动代码从数据库中获取一个单词。我们希望这段代码在一个实现 WordRepository 接口的类中,这是我们定义在领域模型中的。我们将需要设计足够的数据库模式来支持这一点。通过从 Assert 步骤开始添加代码,我们可以快速实现一个实现。这是一个有用的技术——从断言开始编写测试,这样我们就从期望的结果开始。然后我们可以反向工作,包括实现它所需的一切:

  1. 将断言步骤添加到我们的 fetchesWord() 测试中:

        @Test
    
        public void fetchesWord()  {
    
            String actual = "";
    
            assertThat(actual).isEqualTo("ARISE");
    
        }
    

我们希望检查能否从数据库中获取单词 ARISE。这个测试失败了。我们需要创建一个类来包含必要的代码。

  1. 我们希望我们的新适配器类实现 WordRepository 接口,因此我们在测试的 Arrange 步骤中驱动这一点:

        @Test
    
        public void fetchesWord()  {
    
            WordRepository repository
    
                     = new WordRepositoryPostgres();
    
            String actual = "";
    
            assertThat(actual).isEqualTo("ARISE");
    
        }
    
  2. 现在,我们让 IDE 工具在创建我们的新适配器类时做大部分工作。让我们称它为 WordRepositoryPostgres,它连接了两个事实:该类实现了 WordRepository 接口,并且也在实现访问 Postgres 数据库。我们使用 com.wordz.adapters.db

图 14.7 – 新类向导

图 14.7 – 新类向导

这导致类的骨架为空:

package com.wordz.adapters.db;
import com.wordz.domain.WordRepository;
public class WordRepositoryPostgres implements
                                     WordRepository {
}
  1. IDE 将自动生成接口的方法存根:

    public class WordRepositoryPostgres implements WordRepository {
    
        @Override
    
        public String fetchWordByNumber(int number) {
    
            return null;
    
        }
    
    @Override
    
        public int highestWordNumber() {
    
            return 0;
    
        }
    
    }
    
  2. 返回到我们的测试,我们可以添加 act 行,这将调用 fetchWordByNumber() 方法:

        @Test
    
        public void fetchesWord()  {
    
            WordRepository repository
    
                        = new WordRepositoryPostgres();
    
            String actual =
    
                   repository.fetchWordByNumber(27);
    
            assertThat(actual).isEqualTo("ARISE");
    
        }
    

关于传递给 fetchWordByNumber() 方法的神秘常量 27 的解释。这是一个 任意 的数字,用于标识特定的单词。它的唯一硬性要求是它必须与稍后将在 JSON 文件中看到的存根测试数据中的单词编号相匹配。27 的实际值在匹配存根数据中的单词编号之外没有意义。

  1. dataSource 传递给 WordRepositoryPostgres 构造函数,以便我们的类能够访问数据库:

        @Test
    
        public void fetchesWord()  {
    
            WordRepository repository
    
                  = new
    
                    WordRepositoryPostgres(dataSource);
    
            String actual = adapter.fetchWordByNumber(27);
    
            assertThat(actual).isEqualTo("ARISE");
    
        }
    

这导致构造函数发生了变化:

    public WordRepositoryPostgres(DataSource dataSource){
        // Not implemented
    }
  1. 在我们的测试中要做的最后一点设置是将单词 ARISE 填充到数据库中。我们使用 DBRider 框架在测试启动时应用到我们的数据库中的 JSON 文件来完成此操作:

    {
    
      "word": [
    
        {
    
          "word_number": 27,
    
          "word": "ARISE"
    
        }
    
      ]
    
    }
    

这里的 "word_number": 27 代码对应于测试代码中使用的值。

  1. 此文件必须保存在特定位置,以便 DBRider 可以找到它。我们称此文件为 wordTable.json 并将其保存在测试目录中的 /resources/adapters/data

图 14.8 – wordTable.json 的位置

图 14.8 – wordTable.json 的位置

  1. 设置我们的失败测试的最终一步是将测试数据wordTable.json文件链接到我们的fetchesWord()测试方法。我们使用 DBRider 的@DataSet注解来完成此操作:

        @Test
    
        @DataSet("adapters/data/wordTable.json")
    
        public void fetchesWord()  {
    
            WordRepository repository
    
                = new WordRepositoryPostgres(dataSource);
    
            String actual =
    
                        repository.fetchWordByNumber(27);
    
            assertThat(actual).isEqualTo("ARISE");
    
        }
    

测试现在失败了,并且处于我们可以通过编写数据库访问代码使其通过的位置。在下一节中,我们将使用流行的库 JDBI 为我们的WordRepository接口实现适配器类的数据库访问。

实现 WordRepository 适配器

在本节中,我们将使用流行的数据库库 JDBI 来实现WordRepository接口的fetchWordByNumber()方法,并使我们的失败集成测试通过。

第九章中介绍了六边形架构,六边形架构 – 解耦外部系统。外部系统,如数据库,通过领域模型中的端口进行访问。特定于该外部系统的代码包含在适配器中。我们的失败测试使我们能够编写数据库访问代码以获取一个要猜测的单词。

在我们开始编写代码之前,需要做一些数据库设计思考。对于当前任务,我们只需注意,我们将把所有可猜测的单词存储在名为word的数据库表中。这个表将有两个列。将有一个名为word_number的主键和一个名为word的五个字母的单词列。

让我们测试一下:

  1. 运行测试以显示word表不存在:

图 14.9 – 表未找到

图 14.9 – 表未找到

  1. 通过在数据库中创建一个word表来纠正这个问题。我们使用psql控制台运行 SQL create table命令:

    create table word (word_number int primary key,
    
    word char(5));
    
  2. 再次运行测试。错误变为显示我们的ciuser用户权限不足:

图 14.10 – 权限不足

图 14.10 – 权限不足

  1. 我们通过在psql控制台中运行 SQL grant命令来纠正这个问题:

    grant select, insert, update, delete on all tables in schema public to ciuser;
    
  2. 再次运行测试。错误变为显示word尚未从数据库表中读取:

图 14.11 – 未找到单词

图 14.11 – 未找到单词

访问数据库

在设置好数据库方面的事情之后,我们可以继续添加将访问数据库的代码。第一步是添加我们将使用的数据库库。它是 JDBI,为了使用它,我们必须将jdbi3-core依赖项添加到我们的gradle.build文件中:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.mockito:mockito-core:4.8.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
    testImplementation 'com.github.database-rider:rider-core:1.35.0'
    testImplementation 'com.github.database-rider:rider-junit5:1.35.0'
    implementation 'org.postgresql:postgresql:42.5.0'
    implementation 'org.jdbi:jdbi3-core:3.34.0'
}

注意

代码本身如 JDBI 文档中所述,可在以下链接找到:jdbi.org/#_queries

按照以下步骤访问数据库:

  1. 首先,在我们的类构造函数中创建一个jdbi对象:

    public class WordRepositoryPostgres
    
                             implements WordRepository {
    
        private final Jdbi jdbi;
    
        public WordRepositoryPostgres(DataSource
    
                                          dataSource){
    
            jdbi = Jdbi.create(dataSource);
    
        }
    
    }
    

这使我们能够访问 JDBI 库。我们已经安排好,JDBI 将访问我们传递给构造函数的任何DataSource

  1. 我们将 JDBI 代码添加到发送 SQL 查询到数据库并获取我们作为方法参数提供的 wordNumber 对应的单词。首先,我们添加我们将使用的 SQL 查询:

       private static final String SQL_FETCH_WORD_BY_NUMBER
    
         = "select word from word where "
    
                          + "word_number=:wordNumber";
    
  2. jdbi 访问代码可以被添加到 fetchWordByNumber() 方法中:

    @Override
    
    public String fetchWordByNumber(int wordNumber) {
    
        String word = jdbi.withHandle(handle -> {
    
            var query =
    
             handle.createQuery(SQL_FETCH_WORD_BY_NUMBER);
    
            query.bind("wordNumber", wordNumber);
    
            return query.mapTo(String.class).one();
    
        });
    
        return word;
    
    }
    
  3. 再次运行测试:

图 14.12 – 测试通过

图 14.12 – 测试通过

我们现在的集成测试通过了。适配器类已从数据库中读取单词并返回。

实现 GameRepository

相同的过程用于测试驱动 highestWordNumber() 方法并为实现 GameRepository 接口的其它数据库访问代码创建适配器。这些代码的最终版本可以在 GitHub 上看到,其中包含了一些关于数据库测试问题的注释,例如如何避免由存储数据引起的测试失败。

需要一个手动步骤来测试驱动 GameRepository 接口的实现代码。我们必须创建一个 game 表。

在 psql 中,输入以下内容:

CREATE TABLE game (
    player_name character varying NOT NULL,
    word character(5),
    attempt_number integer DEFAULT 0,
    is_game_over boolean DEFAULT false
);

摘要

在本章中,我们为我们的数据库创建了一个集成测试。我们使用它来测试驱动数据库用户的实现、数据库表以及访问我们数据的代码。此代码实现了我们六边形架构中的一个端口适配器。在这个过程中,我们使用了一些新工具。DBRider 数据库测试框架简化了我们的测试代码。JDBI 数据库访问库简化了我们的数据访问代码。

在下一章和最后一章,第十五章驱动 Web 层,我们将向我们的应用程序添加 HTTP 接口,使其成为一个完整的微服务。我们将集成所有组件,然后使用 HTTP 测试工具 Postman 玩我们的第一次 Wordz 游戏。

问题和答案

  1. 我们是否应该自动化创建数据库的手动步骤?

是的。这是 DevOps 的重要部分,我们开发者负责将代码部署到生产环境并保持其运行。关键技术是 基础设施即代码IaC),这意味着将手动步骤作为代码自动化,并将其提交到主仓库。

  1. 哪些工具可以帮助自动化数据库创建?

流行工具是 FlywayLiquibase。两者都允许我们编写在应用程序启动时运行的脚本,并将数据库模式从一种版本迁移到另一种版本。它们在需要迁移数据跨模式更改时提供帮助。这些也超出了本书的范围。

  1. 哪些工具可以帮助安装数据库?

访问运行中的数据库服务器是平台工程的一部分。对于在亚马逊网络服务、微软 Azure 或谷歌云平台运行的云原生设计,使用该平台的配置脚本。一种流行的方法是使用 Hashicorp 的 Terraform,它旨在成为云配置的跨提供商通用脚本语言。这超出了本书的范围。

  1. 我们应该多久运行一次集成测试?

在每次向仓库提交之前。虽然单元测试运行速度快,应该一直运行,但集成测试由于本质上是慢速执行的。在编写领域代码时仅运行单元测试是合理的。我们必须始终确保我们没有意外地破坏任何东西。这就是运行集成测试的用武之地。这些测试会揭示我们是否意外地更改了影响适配器层代码的内容,或者数据库布局是否有所变化。

进一步阅读

  • DBRider 的文档:github.com/database-rider/database-rider

  • JDBI 文档:jdbi.org/#_introduction_to_jdbi_3

  • Flyway 是一个库,允许我们将创建和修改数据库模式的 SQL 命令作为源代码存储。这使得我们可以自动化数据库更改:flywaydb.org/

  • 随着我们的应用程序设计不断发展,我们的数据库模式也需要进行更改。本网站和配套书籍描述了如何在管理风险的同时进行这种更改:databaserefactoring.com/

  • 在亚马逊网络服务上托管 Postgres 数据库,使用他们的 RDS 服务:aws.amazon.com/rds

第十五章:驱动 Web 层

在本章中,我们将通过添加一个网络端点来完成我们的 Web 应用程序。我们将学习如何使用内置的 Java HTTP 客户端编写 HTTP 集成测试。我们将使用开源 HTTP 服务器框架来测试驱动运行此端点的 Web 适配器代码。这个 Web 适配器负责将 HTTP 请求转换为我们在领域层中可以执行的命令。在本章结束时,我们将把应用程序的所有部件组装成一个微服务。Web 适配器和数据库适配器将通过依赖注入与领域模型链接。我们需要运行一些手动数据库命令,安装一个名为 Postman 的网络客户端,然后我们就可以玩游戏了。

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

  • 开始新游戏

  • 玩游戏

  • 集成应用程序

  • 使用应用程序

技术要求

本章的代码可在github.com/PacktPublishing/Test-Driven-Development-with-Java/tree/main/chapter15找到。

在尝试运行最终应用程序之前,请执行以下步骤:

  1. 确保本地运行Postgres数据库。

  2. 确保已完成来自第十四章,“驱动数据库层”的数据库设置步骤。

  3. 打开Postgres pqsl命令终端,并输入以下 SQL 命令:

    insert into word values (1, 'ARISE'), (2, 'SHINE'), (3, 'LIGHT'), (4, 'SLEEP'), (5, 'BEARS'), (6, 'GREET'), (7, 'GRATE');
    
  4. 按照以下说明安装Postmanwww.postman.com/downloads/

开始新游戏

在本节中,我们将测试驱动一个 Web 适配器,该适配器将为我们的领域模型提供一个 HTTP API。外部 Web 客户端可以向此端点发送 HTTP 请求以触发我们的领域模型中的操作,以便我们可以玩游戏。API 将返回适当的 HTTP 响应,指示提交的猜测的分数,并在游戏结束时报告。

以下开源库将被用于帮助我们编写代码:

  • Molecule:这是一个轻量级的 HTTP 框架

  • Undertow:这是一个轻量级的 HTTP 网络服务器,为 Molecule 框架提供动力

  • GSON:这是一个 Google 库,用于在 Java 对象和 JSON 结构化数据之间进行转换

要开始构建,我们首先将所需的库作为依赖项添加到build.gradle文件中。然后我们可以开始编写我们的 HTTP 端点的集成测试,并测试驱动实现。

向项目中添加所需库

在使用这些库之前,我们需要将 Molecule、Undertow 和 Gson 这三个库添加到build.gradle文件中:

将以下代码添加到build.gradle文件中:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.mockito:mockito-core:4.8.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.8.0'
    testImplementation 'com.github.database-rider:rider-core:1.35.0'
    testImplementation 'com.github.database-rider:rider-junit5:1.35.0'
    implementation 'org.postgresql:postgresql:42.5.0'
    implementation 'org.jdbi:jdbi3-core:3.34.0'
    implementation 'org.apache.commons:commons-lang3:3.12.0'
implementation 'com.vtence.molecule:molecule:0.15.0'
    implementation 'io.thorntail:undertow:2.7.0.Final'
    implementation 'com.google.code.gson:gson:2.10'
}

编写失败的测试

我们将遵循正常的 TDD 周期来创建我们的 Web 适配器。在为适配器层中的对象编写测试时,我们必须专注于测试领域层中的对象与外部系统之间的通信之间的转换。我们的适配器层将使用 Molecule HTTP 框架来处理 HTTP 请求和响应。

由于我们已经使用了六边形架构并从领域层开始,我们已经知道游戏逻辑正在工作。这次测试的目标是证明网络适配器层正在履行其职责。也就是说,将 HTTP 请求和响应转换为我们的领域层中的对象。

和往常一样,我们首先创建一个测试类:

  1. 首先,我们编写我们的测试类。我们将称之为 WordzEndpointTest,并且它属于 com.wordz.adapters.api 包:

    package com.wordz.adapters.api;
    
    public class WordzEndpointTest {
    
    }
    

包含这个包的原因是作为我们六边形架构的一部分。在这个 Web 适配器中的代码允许使用领域模型中的任何东西。领域模型本身对这个 Web 适配器的存在一无所知。

我们的第一项测试将是启动一个新的游戏:

@Test
void startGame() {
}
  1. 这个测试需要捕捉围绕我们打算构建的 Web API 的设计决策。一个决策是,当游戏成功启动时,我们将返回一个简单的 204 No Content HTTP 状态码。我们将从断言开始来捕捉这个决策:

    @Test
    
    void startGame() {
    
        HttpResponse res;
    
        assertThat(res)
    
           .hasStatusCode(HttpStatus.NO_CONTENT.code);
    
    }
    
  2. 接下来,我们编写行动步骤。这里的行动是为外部 HTTP 客户端发送一个请求到我们的 Web 端点。为了实现这一点,我们使用 Java 本身提供的内置 HTTP 客户端。我们安排代码发送请求,然后丢弃任何 HTTP 响应体,因为我们的设计不返回体:

    @Test
    
    void startGame() throws IOException,
    
                            InterruptedException {
    
        var httpClient = HttpClient.newHttpClient();
    
    HttpResponse res
    
    = httpClient.send(req,
    
                HttpResponse.BodyHandlers.discarding());
    
        assertThat(res)
    
           .hasStatusCode(HttpStatus.NO_CONTENT.code);
    
    }
    
  3. 安排步骤是我们捕捉关于要发送的 HTTP 请求的决定。为了启动一个新的游戏,我们需要一个 Player 对象来识别玩家。我们将把这个作为 Json 对象发送到 Request 体的内容。这个请求将在我们的服务器上引起状态变化,所以我们选择 HTTP POST 方法来表示这一点。最后,我们选择一个路径为 /start 的路由:

    @Test
    
    private static final Player PLAYER
    
           = new Player("alan2112");
    
    void startGame() throws IOException,
    
                            InterruptedException {
    
        var req = HttpRequest.newBuilder()
    
           .uri(URI.create("htp://localhost:8080/start"))
    
           .POST(HttpRequest.BodyPublishers
    
                .ofString(new Gson().toJson(PLAYER)))
    
                .build();
    
        var httpClient = HttpClient.newHttpClient();
    
        HttpResponse res
    
            = httpClient.send(req,
    
                HttpResponse.BodyHandlers.discarding());
    
        assertThat(res)
    
           .hasStatusCode(HttpStatus.NO_CONTENT.code);
    
    }
    

我们看到 Gson 库被用来将 Player 对象转换为它的 JSON 表示。我们还看到构建了一个 POST 方法并发送到 localhost 上的 /start 路径。最终,我们希望将 localhost 的细节移动到配置中。但,目前,它将使测试在我们的本地机器上工作。

  1. 我们可以运行我们的集成测试并确认它失败:

图 15.1 – 一个失败的测试 – 没有 HTTP 服务器

图 15.1 – 一个失败的测试 – 没有 HTTP 服务器

并非意外,这个测试失败了,因为它无法连接到 HTTP 服务器。修复这个问题是我们的下一个任务。

创建我们的 HTTP 服务器

失败的测试允许我们测试驱动实现 HTTP 服务器的代码。我们将使用 Molecule 库为我们提供 HTTP 服务:

  1. 添加一个端点类,我们将称之为 class WordzEndpoint

        @Test
    
        void startGame() throws IOException,
    
                                InterruptedException {
    
            var endpoint
    
               = new WordzEndpoint("localhost", 8080);
    

传递给 WordzEndpoint 构造函数的两个参数定义了网络端点将运行在的主机和端口。

  1. 使用 IDE,我们生成类:

    package com.wordz.adapters.api;
    
    public class WordzEndpoint {
    
        public WordzEndpoint(String host, int port) {
    
        }
    
    }
    

在这种情况下,我们不会将主机和端口详情存储在字段中。相反,我们将使用 Molecule 库中的类启动一个WebServer

  1. 使用 Molecule 库创建WebServer

    package com.wordz.adapters.api;
    
    import com.vtence.molecule.WebServer;
    
    public class WordzEndpoint {
    
        private final WebServer server;
    
        public WordzEndpoint(String host, int port) {
    
            server = WebServer.create(host, port);
    
        }
    
    }
    

上述代码足以启动一个运行的 HTTP 服务器,并允许测试连接到它。我们的 HTTP 服务器在玩游戏方面没有任何有用的功能。我们需要向这个服务器添加一些路由以及响应它们的代码。

向 HTTP 服务器添加路由

为了变得有用,HTTP 端点必须响应 HTTP 命令,解释它们,并将它们作为命令发送到我们的域层。作为设计决策,我们决定以下内容:

  • 需要调用/start路由来启动游戏

  • 我们将使用 HTTP POST方法

  • 我们将识别游戏属于哪个玩家,作为POST体中的 JSON 数据

要向 HTTP 服务器添加路由,请执行以下操作:

  1. 测试/start路由。为了分步工作,最初,我们将返回NOT_IMPLEMENTED HTTP 响应代码:

    public class WordzEndpoint {
    
        private final WebServer server;
    
        public WordzEndpoint(String host, int port) {
    
            server = WebServer.create(host, port);
    
            try {
    
                server.route(new Routes() {{
    
                    post("/start")
    
                      .to(request -> startGame(request));
    
                }});
    
            } catch (IOException ioe) {
    
                throw new IllegaStateException(ioe);
    
            }
    
        }
    
        private Response startGame(Request request) {
    
            return Response
    
                     .of(HttpStatus.NOT_IMPLEMENTED)
    
                     .done();
    
      }
    
    }
    
  2. 我们可以运行WordzEndpointTest集成测试:

图 15.2 – 错误的 HTTP 状态

图 15.2 – 错误的 HTTP 状态

如预期的那样,测试失败了。我们已经取得了进展,因为测试现在失败的原因不同了。我们现在可以连接到网络端点,但它没有返回正确的 HTTP 响应。我们的下一个任务是连接这个网络端点到域层代码,并采取相关行动来启动一个游戏。

连接到域层

我们下一个任务是接收一个 HTTP 请求并将其转换为域层调用。这涉及到使用 Google Gson 库解析 JSON 请求数据,将其转换为 Java 对象,然后将响应数据发送到Wordz类的端口:

  1. 添加调用作为class Wordz实现的域层端口的代码。我们将使用Mockito为此对象创建一个测试双胞胎。这允许我们仅测试 Web 端点代码,而无需与其他所有代码解耦:

    @ExtendWith(MockitoExtension.class)
    
    public class WordzEndpointTest {
    
        @Mock
    
        private Wordz mockWordz;
    
        @Test
    
        void startGame() throws IOException,
    
                                InterruptedException {
    
            var endpoint
    
            = new WordzEndpoint(mockWordz,
    
                                "localhost", 8080);
    
  2. 我们需要将我们的class Wordz域对象提供给class WordzEndpoint对象。我们使用依赖注入将其注入到构造函数中:

    public class WordzEndpoint {
    
        private final WebServer server;
    
        private final Wordz wordz;
    
        public WordzEndpoint(Wordz wordz,
    
                             String host, int port) {
    
            this.wordz = wordz;
    
  3. 接下来,我们需要添加启动游戏的代码。为此,我们首先从request体中的 JSON 数据中提取Player对象。这确定了为哪个玩家启动游戏。然后我们调用wordz.newGame()方法。如果成功,我们返回 HTTP 状态码204 No Content,表示成功:

    private Response startGame(Request request) {
    
        try {
    
            Player player
    
                    = new Gson().fromJson(request.body(),
    
                                          Player.class);
    
            boolean isSuccessful = wordz.newGame(player);
    
            if (isSuccessful) {
    
                return Response
    
                        .of(HttpStatus.NO_CONTENT)
    
                        .done();
    
            }
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
        throw new
    
           UnsupportedOperationException("Not
    
                                         implemented");
    
    }
    
  4. 现在,我们可以运行测试,然而,它失败了:

图 15.3 – 错误的 HTTP 响应

图 15.3 – 错误的 HTTP 响应

它失败了,因为wordz.newGame()的返回值是 false。需要设置模拟对象以返回true

  1. mockWordz存根返回正确的值:

       @Test
    
    void startsGame() throws IOException,
    
                             InterruptedException {
    
        var endpoint
    
             = new WordzEndpoint(mockWordz,
    
                                 "localhost", 8080);
    
        when(mockWordz.newGame(eq(PLAYER)))
    
              .thenReturn(true);
    
  2. 然后,运行测试:

图 15.4 – 测试通过

图 15.4 – 测试通过

集成测试通过了。HTTP 请求已被接收,调用了领域层代码以启动新游戏,并返回了 HTTP 响应。下一步是考虑重构。

重构启动游戏代码

如往常一样,一旦测试通过,我们就考虑是否需要重构。

将测试重构以简化新测试的编写,将常用代码汇总到一个地方将是有价值的:

@ExtendWith(MockitoExtension.class)
public class WordzEndpointTest {
    @Mock
    private Wordz mockWordz;
    private WordzEndpoint endpoint;
    private static final Player PLAYER
                       = new Player("alan2112");
    private final HttpClient httpClient
                       = HttpClient.newHttpClient();
    @BeforeEach
    void setUp() {
        endpoint = new WordzEndpoint(mockWordz,
                                  "localhost", 8080);
    }
    @Test
    void startsGame() throws IOException,
                             InterruptedException {
        when(mockWordz.newGame(eq(player)))
                              .thenReturn(true);
        var req = requestBuilder("start")
                .POST(asJsonBody(PLAYER))
                .build();
        var res
          = httpClient.send(req,
                HttpResponse.BodyHandlers.discarding());
        assertThat(res)
             .hasStatusCode(HttpStatus.NO_CONTENT.code);
    }
    private HttpRequest.Builder requestBuilder(
        String path) {
        return HttpRequest.newBuilder()
                .uri(URI.create("http://localhost:8080/"
                                  + path));
    }
    private HttpRequest.BodyPublisher asJsonBody(
        Object source) {
        return HttpRequest.BodyPublishers
                 .ofString(new Gson().toJson(source));
    }
}

处理启动游戏时的错误

我们的设计决策之一是玩家在游戏进行中时不能开始游戏。我们需要测试驱动这种行为。我们选择返回 HTTP 状态码409 Conflict,以指示玩家的游戏已经开始,无法为他们启动新的游戏:

  1. 编写测试以返回409 Conflict,如果游戏已经开始:

        @Test
    
        void rejectsRestart() throws Exception {
    
            when(mockWordz.newGame(eq(player)))
    
                             .thenReturn(false);
    
            var req = requestBuilder("start")
    
                    .POST(asJsonBody(player))
    
                    .build();
    
            var res
    
               = httpClient.send(req,
    
                    HttpResponse.BodyHandlers.discarding());
    
            assertThat(res)
    
                   .hasStatusCode(HttpStatus.CONFLICT.code);
    
        }
    
  2. 接下来,运行测试。它应该失败,因为我们还没有编写实现代码:

图 15.5 – 失败的测试

图 15.5 – 失败的测试

  1. 通过测试驱动代码报告游戏无法重启:

    private Response startGame(Request request) {
    
        try {
    
            Player player
    
                    = new Gson().fromJson(request.body(),
    
                                          Player.class);
    
            boolean isSuccessful = wordz.newGame(player);
    
            if (isSuccessful) {
    
                return Response
    
                        .of(HttpStatus.NO_CONTENT)
    
                        .done();
    
            }
    
            return Response
    
                    .of(HttpStatus.CONFLICT)
    
                    .done();
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
    }
    
  2. 再次运行测试:

图 15.6 – 测试通过

图 15.6 – 测试通过

现在实现到位后,单独运行测试时测试通过。让我们运行所有WordzEndpointTests测试以双重检查我们的进度。

  1. 运行所有WordzEndpointTests

图 15.7 – 由于重启服务器导致的测试失败

图 15.7 – 由于重启服务器导致的测试失败

意外的是,当依次运行时,测试失败了。

修复意外失败的测试

当我们运行所有测试时,它们现在都失败了。当单独运行时,所有测试之前都运行正确。最近的一个更改显然破坏了某些东西。我们在某个时候失去了测试隔离。这个错误信息表明,Web 服务器正在同一端口上启动两次,这是不可能的。

选项是每个测试后停止 Web 服务器,或者只为所有测试启动一次 Web 服务器。由于这是一个长期运行的微服务,这里只启动一次似乎是一个更好的选择:

  1. 添加@BeforeAll注解以仅启动 HTTP 服务器一次:

    @BeforeAll
    
    void setUp() {
    
        mockWordz = mock(Wordz.class);
    
        endpoint = new WordzEndpoint(mockWordz,
    
                                     "localhost", 8080);
    
    }
    

我们将@BeforeEach注解更改为@BeforeAll注解,以便端点创建只在每个测试中发生一次。为了支持这一点,我们还必须创建模拟并使用注解在测试本身上以控制对象的生命周期:

@ExtendWith(MockitoExtension.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class WordzEndpointTest {

WordzEndpointTest中的两个测试现在都通过了。

  1. 在所有测试再次通过后,我们可以考虑重构代码。通过提取extractPlayer()方法,我们可以提高可读性。我们还可以使条件 HTTP 状态码更简洁:

    private Response startGame(Request request) {
    
        try {
    
            Player player = extractPlayer(request);
    
            boolean isSuccessful = wordz.newGame(player);
    
            HttpStatus status
    
                    = isSuccessful?
    
                        HttpStatus.NO_CONTENT :
    
                        HttpStatus.CONFLICT;
    
                return Response
    
                        .of(status)
    
                        .done();
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
    }
    
    private Player extractPlayer(Request request)
    
                                     throws IOException {
    
        return new Gson().fromJson(request.body(),
    
                                   Player.class);
    
    }
    

我们现在已经完成了启动游戏所需的主要编码部分。为了处理剩余的错误条件,我们现在可以测试驱动代码,如果无法从 JSON 有效负载中读取Player对象,则返回400 BAD REQUEST。我们在这里省略了那段代码。在下一节中,我们将继续测试驱动猜测目标单词的代码。

游戏玩法

在本节中,我们将测试驱动代码来玩游戏。这涉及到向端点提交多个猜测尝试,直到收到游戏结束的响应。

我们首先为端点中的新/guess路由创建一个集成测试:

  1. 第一步是编写 Arrange 步骤。我们的领域模型在Wordz类上提供了assess()方法来评估猜测的分数,并报告游戏是否结束。为了驱动测试,我们设置了mockWordz存根,当调用assess()方法时返回一个有效的GuessResult对象:

    @Test
    
    void partiallyCorrectGuess() {
    
        var score = new Score("-U---");
    
        score.assess("GUESS");
    
        var result = new GuessResult(score, false, false);
    
        when(mockWordz.assess(eq(player), eq("GUESS")))
    
                .thenReturn(result);
    
    }
    
  2. Act 步骤将调用我们的端点,提交一个包含猜测的 Web 请求。我们的设计决策是向/guess路由发送 HTTP POST请求。request体将包含猜测单词的 JSON 表示。为此,我们将使用record GuessRequest并使用 Gson 将其转换为 JSON:

    @Test
    
    void partiallyCorrectGuess() {
    
        var score = new Score("-U---");
    
        score.assess("GUESS");
    
        var result = new GuessResult(score, false, false);
    
        when(mockWordz.assess(eq(player), eq("GUESS")))
    
                .thenReturn(result);
    
        var guessRequest = new GuessRequest(player, "-U---");
    
        var body = new Gson().toJson(guessRequest);
    
        var req = requestBuilder("guess")
    
                .POST(ofString(body))
    
                .build();
    
    }
    
  3. 接下来,我们定义记录:

    package com.wordz.adapters.api;
    
    import com.wordz.domain.Player;
    
    public record GuessRequest(Player player, String guess) {
    
    }
    
  4. 然后,我们通过 HTTP 将请求发送到我们的端点,等待响应:

    @Test
    
    void partiallyCorrectGuess() throws Exception {
    
        var score = new Score("-U---");
    
        score.assess("GUESS");
    
        var result = new GuessResult(score, false, false);
    
        when(mockWordz.assess(eq(player), eq("GUESS")))
    
                .thenReturn(result);
    
        var guessRequest = new GuessRequest(player, "-U---");
    
        var body = new Gson().toJson(guessRequest);
    
        var req = requestBuilder("guess")
    
                .POST(ofString(body))
    
                .build();
    
        var res
    
           = httpClient.send(req,
    
                HttpResponse.BodyHandlers.ofString());
    
    }
    
  5. 然后,我们提取返回的体数据,并对其与我们的预期进行断言:

    @Test
    
    void partiallyCorrectGuess() throws Exception {
    
        var score = new Score("-U--G");
    
        score.assess("GUESS");
    
        var result = new GuessResult(score, false, false);
    
        when(mockWordz.assess(eq(player), eq("GUESS")))
    
                .thenReturn(result);
    
        var guessRequest = new GuessRequest(player,
    
                                            "-U--G");
    
        var body = new Gson().toJson(guessRequest);
    
        var req = requestBuilder("guess")
    
                .POST(ofString(body))
    
                .build();
    
        var res
    
           = httpClient.send(req,
    
                HttpResponse.BodyHandlers.ofString());
    
        var response
    
           = new Gson().fromJson(res.body(),
    
                             GuessHttpResponse.class);
    
        // Key to letters in scores():
    
        // C correct, P part correct, X incorrect
    
        Assertions.assertThat(response.scores())
    
            .isEqualTo("PCXXX");
    
        Assertions.assertThat(response.isGameOver())
    
    .isFalse();
    
    }
    

在这里的一个 API 设计决策是返回每个字母的分数作为一个五字符的String对象。单个字母XCP用来表示不正确、正确和部分正确的字母。我们在断言中捕捉这个决策。

  1. 我们定义了一个记录来表示我们将作为端点响应返回的 JSON 数据结构:

    package com.wordz.adapters.api;
    
    public record GuessHttpResponse(String scores,
    
                                    boolean isGameOver) {
    
    }
    
  2. 由于我们决定向新的/guess路由POST,我们需要将此路由添加到路由表中。我们还需要将其绑定到一个将采取行动的方法,我们将称之为guessWord()

    public WordzEndpoint(Wordz wordz, String host,
    
                         int port) {
    
        this.wordz = wordz;
    
        server = WebServer.create(host, port);
    
        try {
    
            server.route(new Routes() {{
    
                post("/start")
    
                    .to(request -> startGame(request));
    
                post("/guess")
    
                    .to(request -> guessWord(request));
    
            }});
    
        } catch (IOException e) {
    
            throw new IllegalStateException(e);
    
        }
    
    }
    

我们添加了一个IllegalStateException来重新抛出在启动 HTTP 服务器时发生的任何问题。对于这个应用程序,这个异常可能会向上传播并导致应用程序停止运行。没有工作的 Web 服务器,所有的 Web 代码都没有意义去运行。

  1. 我们使用代码实现了guessWord()方法,用于从POST请求体中提取request数据:

    private Response guessWord(Request request) {
    
        try {
    
            GuessRequest gr =
    
                 extractGuessRequest(request);
    
            return null ;
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
    }
    
    private GuessRequest extractGuessRequest(Request request) throws IOException {
    
        return new Gson().fromJson(request.body(),
    
                                   GuessRequest.class);
    
    }
    
  2. 现在我们有了request数据,是时候调用我们的领域层来完成实际工作了。我们将捕获返回的GuessResult对象,这样我们就可以根据端点上的 HTTP 响应来构建:

    private Response guessWord(Request request) {
    
        try {
    
            GuessRequest gr =
    
                 extractGuessRequest(request);
    
            GuessResult result
    
                    = wordz.assess(gr.player(),
    
                      gr.guess());
    
            return null;
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
    }
    
  3. 我们选择从我们的端点返回与从领域模型返回的GuessResult对象不同的数据格式。我们需要将领域模型的结果转换:

    private Response guessWord(Request request) {
    
        try {
    
            GuessRequest gr =
    
                extractGuessRequest(request);
    
            GuessResult result = wordz.assess(gr.player(),
    
                                 gr.guess());
    
            return Response.ok()
    
                    .body(createGuessHttpResponse(result))
    
                    .done();
    
        } catch (IOException e) {
    
            throw new RuntimeException(e);
    
        }
    
    }
    
    private String createGuessHttpResponse(GuessResult result) {
    
    GuessHttpResponse httpResponse
    
              = new
    
                GuessHttpResponseMapper().from(result);
    
        return new Gson().toJson(httpResponse);
    
    }
    
  4. 我们添加了一个执行转换的空版本的对象,即class GuessHttpResponseMapper。在这个第一步中,它将简单地返回null

    package com.wordz.adapters.api;
    
    import com.wordz.domain.GuessResult;
    
    public class GuessHttpResponseMapper {
    
        public GuessHttpResponse from(GuessResult result) {
    
    return null;
    
        }
    
    }
    
  5. 这就足够编译并运行WordzEndpointTest测试了:

图 15.8 – 测试失败

图 15.8 – 测试失败

  1. 在放置了一个失败的测试之后,我们现在可以测试驱动转换类的细节。为此,我们切换到添加一个名为class GuessHttpResponseMapperTest的新单元测试。

注意

这些细节被省略了,但可以在 GitHub 上找到 – 它遵循本书中使用的标准方法。

  1. 一旦我们通过测试驱动了class GuessHttpResponseMapper的详细实现,我们可以重新运行集成测试:

图 15.9 – 端点测试通过

图 15.9 – 端点测试通过

正如我们在前面的图像中看到的那样,集成测试已经通过!是时候享受一杯应得的咖啡了。嗯,我的选择是美味的英式早餐茶,但那只是我个人的喜好。之后,我们可以测试驱动对发生的任何错误做出的响应。然后是时候将微服务组合在一起了。在下一节中,我们将组装我们的应用程序以运行微服务。

集成应用程序

在本节中,我们将汇集我们的测试驱动应用程序的组件。我们将形成一个运行端点并提供服务前端网络界面的微服务。它将使用 Postgres 数据库进行存储。

我们需要编写一个简短的main()方法来将我们代码的主要组件链接在一起。这涉及到创建具体对象并将依赖注入到构造函数中。main()方法位于class WordzApplication中,这是我们完全集成的网络服务的入口点:

package com.wordz;
import com.wordz.adapters.api.WordzEndpoint;
import com.wordz.adapters.db.GameRepositoryPostgres;
import com.wordz.adapters.db.WordRepositoryPostgres;
import com.wordz.domain.Wordz;
public class WordzApplication {
    public static void main(String[] args) {
        var config = new WordzConfiguration(args);
        new WordzApplication().run(config);
    }
    private void run(WordzConfiguration config) {
        var gameRepository
         = new GameRepositoryPostgres(config.getDataSource());
        var wordRepository
         = new WordRepositoryPostgres(config.getDataSource());
        var randomNumbers = new ProductionRandomNumbers();
        var wordz = new Wordz(gameRepository,
                              wordRepository,
                              randomNumbers);
        var api = new WordzEndpoint(wordz,
                                    config.getEndpointHost(),
                                    config.getEndpointPort());
        waitUntilTerminated();
    }
    private void waitUntilTerminated() {
        try {
            while (true) {
                Thread.sleep(10000);
            }
        } catch (InterruptedException e) {
            return;
        }
    }
}

main()方法实例化了领域模型,并将我们适配器类的具体版本依赖注入其中。一个值得注意的细节是waitUntilTerminated()方法。这防止main()在应用程序关闭之前终止。这反过来又使 HTTP 端点能够响应请求。

应用程序的配置数据存储在class WordzConfiguration中。它为端点主机和端口设置以及数据库连接设置提供了默认设置。这些也可以作为命令行参数传入。该类及其关联的测试可以在本章的 GitHub 代码中找到。

在下一节中,我们将使用 Wordz 网络服务应用程序,使用流行的 HTTP 测试工具 Postman。

使用应用程序

要使用我们新组装的网络应用程序,首先确保技术要求部分中描述的数据库设置步骤和 Postman 安装已成功完成。然后在 IntelliJ 中运行class WordzApplicationmain()方法。这启动了端点,准备接受请求。

一旦服务运行,我们与之交互的方式是通过向端点发送 HTTP 请求。启动 Postman,在 macOS 上,会出现一个类似这样的窗口:

图 15.10 – Postman 主屏幕

图 15.10 – Postman 主屏幕

我们首先需要开始一个游戏。为此,我们需要向端点的/start路由发送 HTTP POST请求。默认情况下,这将可在http://localhost:8080/start处访问。我们需要发送一个包含 JSON 文本{"name":"testuser"}的正文。

我们可以从 Postman 发送这个请求。我们在主页上点击创建请求按钮。这会带我们到一个可以输入 URL、选择POST方法并输入我们的 JSON 正文数据的视图:

  1. 创建一个POST请求来开始游戏:

图 15.11 – 开始新游戏

图 15.11 – 开始新游戏

点击蓝色的testuser。端点按预期执行并发送了 HTTP 状态码204 No Content。这可以在截图的底部看到响应面板中。

快速检查数据库中game表的内容显示,已经为这个游戏创建了一个行:

wordzdb=# select * from game;
 player_name | word  | attempt_number | is_game_over
-------------+-------+----------------+--------------
 testuser    | ARISE |              0 | f
(1 row)
wordzdb=#
  1. 我们现在可以对单词进行第一次猜测。让我们尝试一个猜测"STARE"。这个猜测的POST请求和端点的响应如下所示:

图 15.12 – 返回的分数

图 15.12 – 返回的分数

端点返回 HTTP 状态码200 OK。这次,返回了一个 JSON 格式的数据体。我们看到"scores":"PXPPC"表示我们的猜测的第一个字母S在单词的某个位置出现,但不在第一个位置。我们猜测的第二个字母T是错误的,并且不在目标单词中。我们在猜测中得到了两个更多部分正确的字母和一个最终正确的字母,即末尾的字母E

响应还显示"isGameOver":false。我们还没有完成游戏。

  1. 我们将再猜一次,稍微作弊一下。让我们发送一个"ARISE"

图 15.13 – 成功的猜测

图 15.13 – 成功的猜测

胜利!我们看到"scores":"CCCCC"告诉我们我们猜测的所有五个字母都是正确的。"isGameOver":true告诉我们我们的游戏已经结束,在这种情况下,是成功结束的。

我们已经成功地使用我们的微服务玩了一局 Wordz。

摘要

在本节中,我们已经完成了 Wordz 应用程序。我们使用 TDD 的集成测试来驱动 Wordz 的 HTTP 端点。我们使用了开源的 HTTP 库 – Molecule、Gson 和 Undertow。我们有效地使用了六边形架构。使用端口和适配器,这些框架变成了实现细节而不是我们设计的定义特征。

我们组装了最终的应用程序,将领域层中持有的业务逻辑与 Postgres 数据库适配器和 HTTP 端点适配器结合起来。我们的应用程序一起工作,形成了一个小型微服务。

在这一章的最后一部分,我们到达了一个小型但典型的微服务,它包含一个 HTTP API 和一个 SQL 数据库。我们首先使用测试来指导我们的设计选择,开发了代码。我们应用了 SOLID 原则来改进软件的组装方式。我们学习了如何使用六边形架构的端口和适配器简化与外部系统交互的代码设计。使用六边形架构是 TDD 的自然选择,它允许我们使用 FIRST 单元测试来开发核心应用程序逻辑。我们首先创建了数据库适配器和 HTTP 适配器测试,然后使用集成测试。我们应用了 TDD 的节奏——红、绿、重构和安排、行动和断言到我们的工作中。我们使用 Mockito 库应用了测试替身,以替代外部系统,简化了开发。

在这本书中,我们涵盖了广泛的测试驱动开发(TDD)和软件设计技术。现在我们可以编写出缺陷更少的代码,这使得代码更安全且易于使用。

问答

  1. 还可以做什么进一步的工作?

进一步的工作可以包括添加一个持续集成(CI)管道,这样每次我们提交代码时,应用程序都会从源代码控制中拉取,构建,并运行所有测试。我们可以考虑部署和自动化这个过程。一个例子可能是将 Wordz 应用程序和 Postgres 数据库打包成一个 Docker 镜像。添加数据库模式自动化,使用像 Flyway 这样的工具会很好。

  1. 我们能否替换 Molecule 库,并使用其他东西来构建我们的网络端点?

是的。因为网络端点位于六边形架构的适配器层,所以它不会影响领域模型中的核心功能。任何合适的网络框架都可以使用。

进一步阅读

  • martinfowler.com/articles/richardsonMaturityModel.html

关于 REST 网络接口的概述,以及一些常见的变化

  • Java OOP Done Right,艾伦·梅勒,ISBN 9781527284449

作者的书籍对面向对象(OO)基础和一些有用的设计模式进行了更详细的介绍

一个流行的测试工具,它发送 HTTP 请求并显示响应

  • molecule.vtence.com/

一个轻量级的 Java HTTP 框架

  • undertow.io/

与 Molecule 框架配合良好的 Java HTTP 服务器

  • github.com/google/gson

Google 用于在 Java 对象和 JSON 格式之间转换的库

  • aws.amazon.com/what-is/restful-api/

亚马逊的 REST API 指南

  • docs.oracle.com/en/java/javase/12/docs/api/java.net.http/java/net/http/HttpClient.html

本章中使用的测试 HTTP 客户端的官方 Java 文档

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(33)  评论(0)    收藏  举报