第 9 章:微服务的自动化测试

本章内容概览:

  • 微服务的自动化测试
  • 使用 Jest 进行单元测试和集成测试
  • 使用 Playwright 进行端到端测试

到目前为止,在书中关于构建微服务的讨论中,我们一直采用手动方式测试代码。然而,在本章,我们将探讨如何实施微服务的自动化测试,进一步提升开发效率。

一开始,我们通常通过直接运行代码并肉眼检查输出结果来进行测试。尽管手动测试的方式多样,它在初期是相对可行的。你应该从手动测试开始,直至对自动化测试有了足够的信心。

值得注意的是,只有当产品经过充分理解并确定值得投入自动化测试时,才应采取此步骤。对最小可行产品(MVP)或原型进行自动化测试通常是不经济的,因为这可能会浪费时间并拖慢开发进度——在这些情况下,手动测试已经足够。

然而,当重复的手动测试变得单调乏味且耗时过多时,转向自动化测试成为必然选择。自动化测试不仅在软件开发中普遍适用,而且随着应用程序的扩展,尤其在微服务架构中,其重要性日益凸显。对于规模较小的团队而言,自动化测试也极为关键,因为手动测试的负担可能会变得难以承受。有了先进的自动化测试工具,我们无需再承担繁重的测试任务。

本章将作为微服务测试领域的入门指南。我们将从基本的测试概念入手,接着深入探讨单元测试、集成测试以及端到端测试。

自动化测试是一项高级技术。本书包括此内容是因为自动化测试对于微服务的可扩展性至关重要。如果你尚未接触过自动化测试,可能会觉得本章内容有些挑战。如果是这样,你可以选择跳过本章,待以后有更充分的准备时再回来阅读。记住,虽然在项目初期你可能不需要自动化测试,但随着项目的发展,最终你将需要它来管理超过几个微服务的复杂性。

9.1 新工具

在前一章,我们已将自动化测试集成到了持续集成/持续部署(CI/CD)流程中。本章将教你如何构建自动化测试,以确保我们的微服务既可靠又强健。作为现代开发者,我们可以方便地获取并学习使用优秀的免费测试工具。本章我们将重点介绍两大测试工具:Jest 和 Playwright(见表 9.1),这两者都将用于执行自动化测试。

表 9.1 第 9 章的新工具

工具版本用途
Jest29.6.4Jest 是一个专门用于自动化测试 JavaScript 代码的工具。
Playwright1.37.1Playwright 是一个自动化测试网页的强大工具。

9.2 获取代码

要跟进本章的内容,你需要下载代码或克隆代码仓库:

  • 从此处下载代码的 zip 文件: http://mng.bz/eEgq
  • 你也可以使用 Git 命令来克隆代码仓库:
git clone https://github.com/bootstrapping-microservices-2nd-edition/chapter-9.git

关于安装及使用 Git 的详细帮助,可参考第 2 章。如果在代码实施过程中遇到问题,请在 GitHub 仓库中提出。

9.3 微服务的测试

如同其他任何编码任务,微服务也需经过严密的测试,以确保代码的健壮性、难以被破坏,并能优雅地处理各种问题。测试能使我们放心,因为它确保了代码在正常及异常情况下的稳定运行。

有效的测试应尽可能模拟生产环境,这包括环境设置、代码配置以及测试数据的选择。利用 Docker 和 Docker Compose 来配置测试环境,我们可以确保其与生产环境尽可能一致。

这样可以避免“它在我的电脑上可以运行”的常见借口。通常,如果它在正确配置的 Docker 环境下在我们的电脑上运行正常,我们就可以相信它在生产环境中也会正常运行。提供一个稳定的环境是确保测试可靠性的关键。

虽然手动测试是一个很好的起点,也是值得培养的技能,但正如之前所述,随着应用程序的扩展,自动化测试成为必需。随着微服务数量的增加,我们越发依赖自动化来维持应用的运行效率并加速开发进程。在上一章中,我们已经建立了自动运行测试并部署微服务的 CI/CD 管道。现在,你将学习如何编写那些自动化测试。

9.4 自动化测试

简言之,自动化测试即通过代码驱动的方式来执行并验证代码的正确性。测试代码通常会直接调用被测试的代码,或通过间接方式如 HTTP 请求或 RabbitMQ 消息来进行。之后,测试代码将验证结果是否符合预期,这一验证可以通过检查输出结果或行为表现来实现。

在本章,你将掌握若干自动化测试的关键技巧,并能将其反复应用于你的应用程序,构建一套全面的测试体系。

对于微服务架构,自动化测试可以应用于多个层面:可以是单个函数、一个完整的微服务、一组微服务,或是整个应用程序(直到应用程序规模变得过大;我们将在后文详细讨论)。这些测试层次对应于以下三种自动化测试类型:

  • 单元测试 — 测试孤立的代码片段或单个函数。
  • 集成测试 — 测试完整的微服务。
  • 端到端测试 — 测试一组微服务或整个应用程序,包括前端部分。

这些测试类型并非微服务专有,你可能已经有所耳闻。如果你还不熟悉它们,也无需担心,因为我们将逐一深入探讨。

图 9.1 展示的测试金字塔向我们说明了在测试套件中各类测试的理想比例。

图 9.1 测试金字塔显示了我们应该拥有的每种测试类型的相对数量。
图 9.1 测试金字塔显示了我们应该拥有的每种测试类型的相对数量。

单元测试由于运行速度快,因此我们可以拥有大量此类测试,故位于测试金字塔的基础(底部)。集成测试和端到端测试则位于金字塔的更高层。这些测试类型的运行速度较慢,因此我们不能负担过多此类测试。(金字塔向上逐渐变窄的面积表示我们将拥有越来越少这类测试。)这意味着我们应该拥有比单元测试更少的集成测试,比集成测试更少的端到端测试。

图 9.2 展示了 FlixTube 简化版本的端到端测试是什么样子。端到端测试是最容易理解的测试类型,因为它模拟了客户如何使用我们的产品;因此,它是最接近手动测试的自动化测试类型。我们加载整个应用程序进行测试,与手动测试时相似。图 9.2 显示了使用 Playwright 对运行在 Docker Compose 上的应用程序简化版本进行的测试。

图 9.2 使用 Playwright 对简化版 FlixTube 进行端到端测试
图 9.2 使用 Playwright 对简化版 FlixTube 进行端到端测试

自动化测试结合 CI/CD 管道,就像一个早期警报系统。当警报响起时,我们可以庆幸,因为它为我们提供了在问题进入生产阶段之前加以阻止的机会。

注意 自动化测试的真正价值在于它将为我们节省无数小时的常规测试,并有助于防止可能进入生产阶段并引发混乱的错误代码。

尽管自动化测试令人兴奋,但它并非解决所有问题的万能钥匙。自动化测试不能完全替代真人进行的探索性测试(例如手动测试),这种测试仍然必不可少。因为它是发现那些开发团队甚至未曾想到的错误的唯一方式。

更进一步,自动化测试不仅仅用于验证代码的功能性,它还是一种极具价值的沟通工具,一种可执行的文档,清晰地展示了代码的预期用途。同时,它为我们提供了一个安全的框架,允许我们对应用程序进行重构和优化,不断地推动架构向更简洁、更优雅的方向发展。

让我们深入每种测试类型,并通过观察应用于元数据微服务和 FlixTube 应用程序的具体测试示例,来了解这些测试的实际运用。

9.5 使用 Jest 进行自动化测试

考虑到测试是一个广泛的主题,我们将从简单的例子入手,这些例子虽然与微服务没有直接联系,但适用于测试各种 JavaScript 代码,无论是前端、后端还是移动或桌面应用程序。

如果你已经熟悉如何使用 Jest 进行自动化测试并理解如何进行模拟,可以跳过本节,直接阅读 9.6 节,在那里我们将讨论如何将自动化测试与微服务相结合。

假设我们正在为一个微服务创建一个 JavaScript 数学库。我们将采用 Jest 进行测试,如图 9.3 所展示。

图 9.3 使用 Jest 进行自动化测试
图 9.3 使用 Jest 进行自动化测试

在图的左侧,我们有 math.test.js 文件,里面包含了我们将对数学库执行的测试。右侧是包含数学库代码的 math.js 文件。运行 Jest 时,它会加载我们的测试代码,然后执行我们正在测试的代码。我们的测试将直接调用代码,验证它是否按预期工作。

9.5.1 为什么选择 Jest?

Jest 是目前最受欢迎的 JavaScript 测试工具之一,它的设置简单,配置需求低,特别适合初学者。Jest 的执行速度快,能并行执行测试,并支持实时重载;在观察模式下运行时,你可以边编码边执行测试。

由 Facebook 开发的 Jest 不仅支持强大,还拥有庞大的用户社区和众多社区贡献者。其 API 范围广泛,支持多种测试风格,提供丰富的验证测试和模拟创建方法。

本章我们将不深入介绍其它功能。(在章节末尾,你会找到更多关于 Jest 的信息的链接。)Jest 是开源的,免费使用,相关代码可以在这里找到: https://github.com/facebook/jest

9.5.2 设置 Jest

我们的起点是第 9 章代码仓库中的示例 1。你可以运行这些测试,看看做出改动后会发生什么。示例 1 的 package.json 文件已经包括了 Jest,因此我们只需简单地安装项目依赖:

cd chapter-9/example-1
npm install

如果你是在新项目中安装 Jest,可以使用以下命令:

npm install --save-dev jest

我们使用 --save-dev 参数把 Jest 保存为开发依赖项,因为我们只在开发或测试环境中使用 Jest,从而将其从生产环境中排除。

列表 9.1 展示了示例 1 的 Jest 配置文件(chapter-9/example-1/jest.config.js),这实际上是 Jest 生成的默认配置。除了去掉了生成时的许多有用注释,我没有做出其他修改。

列表 9.1 Jest 配置文件

module.exports = {
  clearMocks: true, // 每次测试后自动清理模拟
  testEnvironment: "node", // 设置 Node.js 测试环境
};

如果是全新项目,你可以通过以下命令创建自己的 Jest 配置文件:

npx jest --init

作为提醒,npx 是随 Node.js 一起提供的工具,允许我们以命令行应用程序的形式运行 npm 模块。许多 npm 可安装模块,包括 Jest,都可通过 npx 运行。你可能还记得我们在第 5 章中使用 npx 运行的 wait-port 命令。

图 9.4 展示了安装了 Jest 的示例 1 项目的结构。你可以看到每个 Node.js 项目中常见的 package.jsonpackage-lock.json 文件。对于 Jest 而言,特别注意项目包含了 Jest 的配置文件(如列表 9.1 所示)以及代码和测试文件。数学库的代码在 math.js 中,测试代码在 math.test.js 中。像其他 npm 模块一样,Jest 也安装在了 node_modules 目录下。

图 9.4 一个具有典型结构的 Node.js 项目安装了 Jest
图 9.4 一个具有典型结构的 Node.js 项目安装了 Jest

注意测试文件的命名是根据其测试的代码命名的。在创建 math.test.js 时,我们简单地在库名后附加了 .test.js。这种命名约定是 Jest 定位我们的测试代码的方式。这是 Jest 的默认约定,但如果我们想要不同的命名约定,我们可以进行配置。

测试文件(math.test.js)通常与代码文件(math.js)放在同一目录中,这是一个普遍的做法。我们也可以将这两个文件放在项目的任何地方,这不会造成太大差异。另一个常见的做法是将所有测试与应用程序代码分开,并放在 src 子目录下的 testtests 子目录中。

你可能已经注意到,Jest 配置文件实际上是一个 JavaScript 文件。这意味着你可以在配置中使用 JavaScript 代码。对于 JavaScript 和 Node.js 工具来说,具有可执行配置文件是非常常见的,我认为使用 JavaScript 作为其自己的配置语言非常酷。

9.5.3 要测试的数学库

现在想象我们已经为我们的新数学库添加了第一个函数。下面的列表(chapter-9/example-1/src/math.js)展示了 square 函数。这是一个简单的函数,接受一个数字并返回该数字的平方。

function square(n) { // 一个简单的 JavaScript 函数,计算一个数字的平方。这就是我们将要测试的代码。
  return n * n;
}
--snip-- // 我们可以在这里添加更多函数到我们的数学库中,随着我们的开发。
module.exports = { // 导出“square”函数,以便我们可以在我们的代码模块中使用它。这也是我们如何从我们的测试代码中访问它的方式。
  square,
--snip-- // 随着我们向数学库添加其他函数,这里也会导出其他函数。
};

未来,我们会在 math.js 中添加更多的函数。但现在,我们将保持简短,以便它可以作为自动化测试的简单示例。

9.5.4 我们的第一个 Jest 测试

square 函数是一个简单的函数,结果也简单,而更复杂的函数总是依赖于像这样的简单函数。为了确保复杂的函数工作,我们必须首先测试简单的函数。即使这个函数很简单,我们仍然需要测试它。

列表 9.3(chapter-9/example-1/src/math.test.js)展示了测试我们新数学库的代码。describe 函数定义了一个名为 square function 的测试套件。test 函数定义了我们的第一个测试,名为 can square two

列表 9.3 使用 Jest 的第一个测试

const { square } = require("./math"); // 导入我们正在测试的代码

describe("square function", () => { // 创建一个名为 "square function" 的测试套件

  test("can square two", () => { // 创建一个名为 "can square two" 的测试
    const result = square(2); // 调用 square 函数并捕获结果
    expect(result).toBe(4); // 设置一个预期结果应为 4。如果预期不满足,测试失败。
  });

});

我们以它正在测试的函数的名称命名了这个测试套件 square function。你可以想象在未来我们可能会在这个文件中为数学库中的其他函数添加其他测试套件。

在列表 9.3 中,我们从 math.js 文件中导入了 square 函数。在我们的 can square two 测试中,我们用数字 2 作为输入调用它。你可以看到我们已经仔细命名了测试以表明其目的。

注意 一个好的测试名称可以让我们立即理解正在测试什么。

然后我们使用 expecttoBe 函数来验证 square 函数的结果是数字 4。可以将各种函数组合链式调用到 expect 函数上(查看 Jest 文档获取更多示例: https://jestjs.io/docs/en/expect ,这为描述被测试代码的预期输出提供了丰富的语法。

9.5.5 运行我们的第一个测试

现在我们准备运行 Jest 并查看成功测试运行的样子(剧透警告,我提前知道这段代码是工作的)。在 example-1 目录的终端中,按以下方式运行测试:

npx jest

你可以在图 9.5 中看到成功测试运行的输出。我们有一个测试和一个测试套件;都已成功完成。

图 9.5 我们使用 Jest 成功运行测试的输出
图 9.5 我们使用 Jest 成功运行测试的输出

9.5.6 使用 Jest 的实时重新加载

实时重新加载对于开发者的生产力很重要,特别是在测试时。在编码和编写测试时,我们可以以实时重新加载模式运行 Jest,如下所示:

npx jest --watchAll

该命令适用于所有项目,并在任何代码更改时运行所有测试。如果你正在使用 Git,你也可以使用这个命令:

npx jest --watch

第二个版本的性能更好,因为它使用 Git 知道哪些文件已更改(而不是盲目运行所有测试)。

使用实时重新加载是一个很好的工作方式。我们可以更改一些代码,测试会自动运行以显示我们所做的更改是否破坏了任何东西。

9.5.7 解释测试失败

当我们的测试通过时一切都好,但当我们的代码出现问题并且我们的测试失败时会怎样呢?不要等到你无意中破坏你的代码才发现!

现在就试试吧。这很简单,只需更改我们代码的行为。例如,尝试将 square 函数更改为返回错误的结果:

function square(n) {
  return n & n;
}

注意我用二进制 AND 操作符(&)替换了乘法操作符。让我们看看我们的测试对此有何看法。

你可以在图 9.6 中看到失败测试的输出。当测试失败时,Jest 以非零退出代码结束。这表明发生了失败。这是我们的 CI/CD 管道检测到我们的测试失败的方式。

图 9.6 Jest 中失败测试的输出
图 9.6 Jest 中失败测试的输出

这个测试失败是因为我们更改了代码的预期行为。在这种情况下,我们故意破坏了我们自己的代码以查看结果,但你也可以想象在我们的常规开发过程中,一个简单的打字错误会导致这样的生产代码问题。如果没有自动化测试,这个问题很容易在手动测试中被遗漏,稍后可能会被客户发现。这至少是尴尬的,但根据实际错误的性质,它可能会对业务造成真正的问题。

当然,我们的目标不仅仅是测试 square 函数。单靠这一个测试并不能有效地覆盖所有的代码场景。我们需要构建一系列的测试用例,以尽可能全面地覆盖我们的代码。

这些测试集合构成了一个自动化验证系统,可以验证我们的代码是否按照预期运行。更为重要的是,它能确保随着我们对代码的不断改进和扩展,它仍能持续工作。

值得注意的是,我们可以通过抛出异常来模拟代码中的任何潜在失败点:

throw new Error("This shouldn't happen.");

面对潜在的错误无畏不惧的最好方式是在我们自己的代码中主动寻找和触发这些错误。当我们看到所有可能的错误时,就可以消除对它们的恐惧,从而更专注于理解和解决问题。这种通过模拟或故意引发问题来确保我们的应用能优雅处理问题的做法,被称为混沌工程(参见第 11 章了解更多相关信息)。

9.5.8 通过 npm 调用 Jest

在第 2 章中,我们探讨了在 package.json 文件中添加 npm 脚本的方法,这样我们就可以使用常见的 npm 命令,如 npm start。这里,我们将设置一个测试脚本,使我们可以通过以下方式运行测试套件:

npm test

这种做法意味着我们可以轻松地为任何 Node.js 项目运行测试,而无需关心项目的具体细节,比如它是否使用 Jest 或其他测试工具。实际上,如本章后面将展示的,我们也将使用相同的命令来运行 Playwright 测试。列表 9.4(chapter-9/example-1/package.json)展示了配置为运行 Jest 的 package.json

列表 9.4 package.json 中运行 Jest 的 npm 脚本

{
  "name": "example-1",
  "version": "1.0.0",
  "scripts": {
    "test": "jest", // 通过调用 npm test 设置运行 Jest
    "test:watch": "jest --watchAll" // 设置以实时重新加载模式运行 Jest
  },
  "devDependencies": {
    "jest": "^29.6.4" // 将 Jest 安装为开发依赖
  },
  "dependencies": {}
}

在列表 9.4 中,还有一个名为 test:watch 的 npm 脚本。这种配置允许我们像这样以实时重新加载模式运行我们的测试:

npm run test:watch

test:watch 脚本是我的个人习惯用法——这不是 npm 的标准。我使用它的原因是无论我使用哪种测试工具,都能轻松记住如何启用实时重新加载来运行我的测试。

9.5.9 充实我们的测试套件

到目前为止,我们只看到了一个测试,但我还想让你感受一下当我们发展到一个包含许多测试的测试套件时会是什么样子。列表 9.5(对 chapter-9/example-1/src/math.test.js 的补充)展示了在添加第二个测试后 math.test.js 的样子。(示例 1 实际上不包含这个新测试,但你可以自行添加并试验它。)

列表 9.5 添加下一个测试

const { square } = require("./math");

describe("square function", () => {

  test("can square two", () => {
    // 为简洁起见省略之前的测试
    --snip--
  });

  test("can square zero", () => {
    const result = square(0);
    expect(result).toBe(0);
  });

  --snip-- // 在这里为 square 函数测试套件添加更多测试。
});

--snip-- // 在这里为数学库添加更多测试套件。

如列表 9.5 所示,我们可以通过在测试套件的 describe 函数中添加更多的 test 函数实例来为我们的 square 函数测试套件添加更多测试。

新的测试 can square zero 是一个边缘案例的例子。我们不需要添加更多测试来计算正数的平方;can square two 已足以涵盖所有正面情况,因此我们可以将其重命名为 can square positive number。如果你想完成这个小测试套件,你应该也添加一个名为 can square negative number 的测试。

随着我们开发数学库,我们将添加更多数学函数和更多测试套件。例如,我们将添加函数如 squareRootaverage 及其测试套件 square root functionaverage function。请记住,我们将测试文件命名为 math.test.js,这个名称足够通用,我们可以使用 describe 函数向其中添加新的测试套件。

我们也可以为每个测试套件有单独的 JavaScript 代码文件,例如 square.test.js, square-root.test.jsaverage.test.js。注意,这些都是以 .test.js 结尾,以便 Jest 能自动找到它们。随着我们未来添加新库,我们将添加新的测试文件,根据需要添加尽可能多的测试文件,以包含我们创建的所有测试。

你可以按照任何你想要的方式结构化你的测试。你可以按你喜欢的方式命名测试,并跨各种文件结构化它们以满足你的需求。然而,在为公司工作时,你将被期望遵循他们现有的风格和惯例。无论你遵循哪种约定,我只请求(代表全世界的开发人员),你使用有意义的测试名称——名称使人容易理解测试的目的。

9.5.10 使用 Jest 进行模拟

JavaScript 由于其动态特性,非常适合创建模拟对象,这使得在自动化测试中模拟特别容易实现。首先,我们来定义什么是模拟

定义 模拟 是指在代码中用虚拟或模拟的版本替换真实的依赖项。

我们可以模拟的依赖项包括函数、对象,甚至是整个模块。在 JavaScript 中,快速创建新的函数、对象和数据结构作为模拟是非常简单的。

模拟的主要目的是隔离正在测试的代码,确保测试仅关注该代码部分,而不是外部依赖或其他模块。这种隔离在单元测试和测试驱动开发(TDD)中尤为重要。

此外,模拟可以排除那些可能拖慢测试速度的代码和流程,例如数据库查询、网络请求和文件系统操作等。这些操作通常较为耗时,并且与我们需要测试的代码逻辑无关。

在第 9.6 节,你将看到单元测试的具体应用,并会接触到一个实际的模拟例子。但在此之前,我们先通过一个简单的例子来理解模拟的应用。假设我们不在 square 函数中直接使用乘法运算符(*),而是调用一个 multiply 函数:

function square(n) {
    return multiply(n, n);
}

你可能会问,为何要使用一个函数来完成乘法运算,尤其是当我们已经有了乘法运算符?这是为了提供一个简单的案例来解释模拟的用途。例如,假设我们的数学库需要处理不仅仅是数字,还包括向量(数字数组)。这种情况下,multiply 函数可能变得复杂,需要在 GPU 上并行处理,这就是我们使用单独的乘法函数的一个合理原因。

现在,为了隔离 square 函数中的代码,我们需要模拟 multiply 函数。这意味着我们需要用一个可控的函数来替换它。我们可以通过依赖注入(DI)实现这一点,DI 是一种技术,通过它我们可以将依赖注入到代码中,而不是硬编码。这对于隔离代码进行单元测试非常有用。以下是将 multiply 函数作为参数注入到 square 函数中的方法:

function square(n, multiply) {
    return multiply(n, n);
}

在 JavaScript 中,函数是一等公民,因此可以像其他任何值一样传递。

现在我们在测试中使用这种方法。调用 square 函数时,我们会传入模拟的 multiply 函数:

test("can square two", () => {
    const mockMultiply = (n1, n2) => {
        expect(n1).toBe(2);  // 验证正确的参数传递给 multiply
        expect(n2).toBe(2);
        return 4;  // 返回硬编码的结果
    };

    const result = square(2, mockMultiply);  // 使用模拟的 multiply 函数而非真实函数

    expect(result).toBe(4);  // 验证结果
});

这种方式可能让人疑惑:我们的模拟函数返回了一个硬编码的值,这样的测试有什么意义?其实,这种测试验证了 square 函数是否正确地使用了 multiply 函数,并从中得到了预期的结果。

注意,我们已经定义了 square 函数,并通过测试验证了它的有效性——尽管 multiply 函数还未实现。这就体现了测试驱动开发(TDD)的强大之处,允许我们在完整的函数实现之前,确保其逻辑的正确性。

最终,为了使这段代码在生产环境中有效,我们还需要实现 multiply 函数,并对它进行自动化测试。

虽然这是一个虚构的例子,但它很好地引入了模拟的概念。在实际应用中,这种细粒度的依赖注入可能比较少见。不久后,你会看到更实际的例子,其中用模拟来替换整个代码模块。

9.5.11 我们实现了什么?

我们已经介绍了如何使用 Jest 进行 JavaScript 代码的单元测试,并演示了如何使用模拟来隔离和测试特定的代码片段。通过模拟,我们确保仅测试目标代码,而非整个应用或依赖库,这使测试更为快速和精确。

9.6 微服务的单元测试

微服务的单元测试和其他类型的单元测试并无二致。它的核心目标是对代码中的单个单元进行独立测试,以实现与其他代码的隔离。那么,什么构成了一个单元呢?通常情况下,每个单元测试针对一个特定的函数或函数的一个具体方面进行。

单元测试的关键在于实现隔离。当我们对代码进行隔离测试时,我们的关注点仅限于那一小块代码。例如,我们可能会对元数据微服务的特定代码进行测试,而对如 Express 库或 MongoDB 库这样的依赖则不加以考虑。我们假设这些依赖已经过了充分的测试。相反,我们的测试焦点仅在于我们自己编写的代码。为了聚焦于自家代码,我们需要排除所有其他的代码。

通过模拟其依赖关系,我们可以实现代码的隔离。对于我们的元数据微服务来说,这意味着我们用可控且随意操作的假实体替代真实的 Express 和 MongoDB 库。

隔离也是单元测试能够快速执行的原因。相较于集成测试和端到端测试,单元测试不涉及代码的集成,而是专注于隔离的代码片段。

在执行单元测试时,我们不会启动实际的 HTTP 服务器或连接到真实的数据库。这种排除慢速依赖的做法,加快了单元测试的运行速度,也是它们构成测试金字塔(参见图 9.1)底层的原因。我们可以为代码设置数百甚至数千个单元测试,而无需花费大量时间等待单元测试套件的完成。

我们将采用 Jest 来执行我们的单元测试。如图 9.7 所示,我们的测试代码来自 index.test.js(左侧),由 Jest 加载。我们需要测试的代码,即元数据微服务的代码,来源于 index.js(右侧),由我们的测试代码加载。

我们将模拟 Express 和 MongoDB,而不是使用真实的库。测试代码“启动”了我们的微服务,但并非以常规方式。由于 Express 被模拟,因此我们并未启动真实的 HTTP 服务器。同理,由于 MongoDB 被模拟,我们也未连接到实际的数据库。

图 9.7 使用 Jest 对元数据微服务进行单元测试
图 9.7 使用 Jest 对元数据微服务进行单元测试

9.6.1 元数据微服务

我们现在转向第 9 章代码库中的示例 2。要跟随进行,你需要安装依赖项:

cd chapter-9/example-2
npm install

列表 9.6(chapter-9/example-2/src/index.js)展示了我们将要测试的代码。这是一个初出茅庐的微服务,将成为 FlixTube 的元数据微服务。它是一个 REST API,旨在收集、存储、搜索和管理每个视频的相关元数据。列表中的基本设置与我们在第 2 章中的第一个微服务相似,但进行了适当调整。

列表 9.6 用于单元测试的元数据微服务

const express = require("express");
const mongodb = require("mongodb");

async function startMicroservice(dbhost, dbname, port) {
  const client = await mongodb.MongoClient.connect(dbhost);
  const db = client.db(dbname);
  const videosCollection = db.collection("videos");

  const app = express();

  app.get("/videos", async (req, res) => {
    const videos = await videosCollection.find().toArray(); // 从数据库中查找视频记录
    res.json({ videos: videos }); // 以 JSON 形式返回视频列表
  });

  // 其他路由处理器在此处

  const server = app.listen(port); // 启动 Express HTTP 服务器
  return {
    close: () => {
      server.close(); // 关闭 Express HTTP 服务器
      client.close(); // 关闭数据库连接
    },
    db: db, // 允许测试代码访问数据库
  };
}

async function main() {
  const PORT = process.env.PORT;
  const DBHOST = process.env.DBHOST;
  const DBNAME = process.env.DBNAME;

  await startMicroservice(DBHOST, DBNAME, PORT);
}

if (require.main === module) {
  main()
    .then(() => console.log("Microservice online."))
    .catch(err => {
      console.error("Microservice failed to start.");
      console.error(err && err.stack || err);
    });
} else {
  module.exports = {
    startMicroservice, // 否则,在测试下运行,因此导出函数以允许测试控制微服务的启动方式
  };
}

列表 9.6 采用 Express 库启动了一个 HTTP 服务器,并通过 MongoDB 库连接到 MongoDB 数据库。我们为 HTTP GET /videos 路由添加了一个处理函数,该路由功能是从数据库检索视频元数据的数组。

我们在此处测试的代码通过调用 startMicroservice 函数来执行。这是我们为了让微服务更易于测试而新增的函数。调用 startMicroservice 将返回一个代表微服务的 JavaScript 对象。虽然单元测试不需要这个对象,但在进行集成测试时会用到它。我们对微服务的结构进行了这种修改,以便于“为测试而设计”,常常通过调整代码来优化其测试适应性。

请注意,我们的测试不局限于调用 startMicroservice。实际上,我们可以测试代码库中导出的任何函数。例如,我们可以添加之前的数学库,并以同样的方式进行测试。请记住,这正是单元测试的真正目的:独立测试每一个函数。

9.6.2 使用 Jest 创建单元测试

在进行代码单元测试前,我们首先需要为依赖项设置模拟。在本例中,我们的依赖包括 Express 和 MongoDB。在其他情况下,依赖可能不同;例如,在另一个微服务中,我们可能需要模拟与 RabbitMQ 通信的 amqp 库。

代码清单 9.7(摘自 chapter-9/example-2/src/index.test.js)展示了我们的测试代码。此文件定义了名为“元数据微服务”的测试套件,并包含三个测试用例。我们将此文件命名为 index.test.js,以表示它主要针对 index.js 中的代码进行测试。随着微服务的持续开发,我们将增加更多此类文件,以实现对微服务所有代码的全覆盖测试。

测试套件首先聚焦于为 Express 和 MongoDB 库配置模拟。请注意,我们使用 jest.fn 创建了模拟函数,这些函数能够帮助我们检测函数的调用情况及其传递的参数。随后,通过 jest.doMock,我们能够模拟整个 Node.js 模块。这些工具极其强大,使我们能够替代 Express 和 MongoDB,同时无需对正在测试的代码作任何修改。

代码清单 9.7 省略了部分测试,这些可以在完整示例代码中找到。包含在清单 9.7 中的测试直接触发了 /videos 路由处理函数,并验证其是否能从数据库中成功检索到所需数据。

这个例子虽然高级,但目的是直接展示与微服务相关的单元测试操作。如果你对这些代码感到困惑,不必过于担心。只需阅读并理解它们的关键点,辨识出哪些部分用于模拟,哪些部分用于实际的测试操作。

代码清单 9.7 使用 Jest 测试元数据微服务

describe("metadata microservice", () => { // 定义元数据微服务的测试套件
  const mockListenFn = jest.fn(); // 创建模拟的 listen 函数
  const mockGetFn = jest.fn(); // 创建模拟的 get 函数

  jest.doMock("express", () => { // 模拟 Express 库
    return () => {
      return {
        listen: mockListenFn,
        get: mockGetFn, // Express 库为创建应用对象提供的工厂函数
      };
    };
  });

  const mockVideosCollection = {}; // 模拟 MongoDB 的视频集合

  const mockDb = { // 模拟 MongoDB 数据库
    collection: () => {
      return mockVideosCollection;
    },
  };

  const mockMongoClient = { // 模拟 MongoDB 客户端对象
    db: () => {
      return mockDb;
    },
  };

  jest.doMock("mongodb", () => { // 模拟 MongoDB 模块
    return {
      MongoClient: { // 模拟 MongoClient
        connect: async () => { // 模拟 connect 函数
          return mockMongoClient;
        },
      },
    };
  });

  const { startMicroservice } = require("./index"); // 导入待测试代码

  --省略部分代码--

  test("/videos route retrieves data", async () => { // 测试 /videos 路由能否从数据库检索数据
    await startMicroservice("dbhost", "dbname", 3000); // 初始化微服务

    const mockRequest = {}; // 模拟传递给 Express 路由处理程序的请求和响应对象
    const mockJsonFn = jest.fn();
    const mockResponse = {
      json: mockJsonFn
    };

    const mockRecord1 = {}; // 模拟数据库记录
    const mockRecord2 = {};

    mockVideosCollection.find = () => {
      return {
        toArray: async () => {
          return [mockRecord1, mockRecord2];
        }
      };
    };

    const videosRouteHandler = mockGetFn.mock.calls[0][1]; // 提取 /videos 路由处理函数
    await videosRouteHandler(mockRequest, mockResponse); // 执行路由处理函数,触发测试代码

    expect(mockJsonFn.mock.calls.length).toEqual(1); // 检验 json 函数是否调用
    expect(mockJsonFn.mock.calls[0][0]).toEqual({ // 检验是否正确检索到模拟记录
      videos: [mockRecord1, mockRecord2],
    });
  });

  --省略部分代码-- // 这里还有更多测试!
});

可能会有疑问,代码清单 9.7 中为何没有导入 jest 的 require 语句!其实这是因为代码运行在 Jest 环境下,Jest 会自动导入 jest 变量。这是一种省心的做法,节省了我们一行代码。

代码清单 9.7 的主要部分集中在创建替代 Express 和 MongoDB 的模拟上。我们使用 jest.fnjest.doMock 来构建这些模拟。Jest 还提供了许多其他有用的函数,用于模拟操作和设定测试预期。更多关于如何使用 Jest 进行模拟的信息,请参考本章末尾的参考资料。

我们用新的 JavaScript 对象替代了 Express 和 MongoDB,从而为我们正在测试的代码的依赖项提供了替代实现。当代码调用这些依赖时,实际上调用的是我们提供的替代版本,而非真正的 Express 和 MongoDB 库中的函数。

如果未替换 Express 和 MongoDB,则调用 startMicroservice 将启动实际的 HTTP 服务器并连接到真实的数据库。这正是我们在进行单元测试时想要避免的情况!因为这样的操作会显著减慢自动化测试的运行速度。虽然目前只是少量测试,但当你开始运行数百甚至数千个测试时,这种差异会变得非常明显。

9.6.3 执行测试

在完成代码编写和测试编写后,我们可以开始运行 Jest 来执行单元测试。在 example-2 目录的命令行界面中,可使用以下命令来运行测试:

npx jest

或者,你也可以使用:

npm test

测试结果应显示一个成功的测试套件,包括三项通过的测试。

9.6.4 我们实现了什么?

至此,你已经掌握了使用 Jest 进行微服务单元测试的基础。我们通过模拟 Express 和 MongoDB 库,并验证了微服务的启动及其 /videos 路由的数据库记录检索功能。

虽然这些测试似乎简单,但它们是确保代码质量的关键起点。你可以继续编写更多此类测试,以全面覆盖微服务中的所有代码。你甚至可以尝试采用测试驱动开发(TDD)方法,即在编写实际被测试代码之前,先编写测试代码。这是一种将测试置于开发前列的强大策略,有助于编写更加健壮、易于测试的代码。

9.7 集成测试

在测试金字塔中的下一层是集成测试。与单元测试中的代码隔离测试不同,集成测试的重点是测试代码模块的整体集成运行。对于微服务而言,集成测试通常涉及测试整个服务,包括所有依赖的模块和代码库。

虽然我们希望单元测试能够捕捉所有问题,但现实是它们无法检测到模块间可能存在的隐患。单元测试虽然运行迅速,能够频繁执行,从而快速发现问题,但仍有许多问题可能隐藏在代码模块之间的互动中。

注意:正确的测试策略需要平衡,我们需要集成测试来发现代码集成中的潜在问题。

通常,我们通过官方的 HTTP 接口与微服务进行集成测试,而不是像单元测试中那样直接调用函数。根据微服务的具体实现,我们还可以通过其他方式与其交互,例如,如果微服务使用了 RabbitMQ,我们也可以通过发送消息进行测试。

图 9.8 展示了我们如何使用 Jest 对元数据微服务进行集成测试。这次,我们不使用 Jest 的模拟功能,而是通过发送 HTTP 请求并检查响应来测试微服务。

图 9.8 使用 Jest 对微服务进行集成测试
图 9.8 使用 Jest 对微服务进行集成测试

9.7.1 待测试的代码

现在,让我们在第 9 章的代码库中转到 example-3。你可以跟随进行并运行这些测试。我们将测试的代码与 example-2 中的代码相同;没有变化,所以如果你想回顾之前的代码,请查阅列表 9.6。

9.7.2 启动 MongoDB 数据库

进行集成测试时,我们不使用模拟的数据库替代。相反,我们需要一个真实的数据库,并加载真实的测试数据。

要进行 example-3 的集成测试,你需要运行一个真实的 MongoDB 数据库。下载并安装 MongoDB 并不复杂。如果你还未安装,请根据你的平台指南操作:https://docs.mongodb.com/manual/installation/。

或者,作为替代方案,你可以在 example-3 中使用 Docker Compose 文件在 Docker 容器中启动 MongoDB:

cd chapter-9/example-3
docker compose up

9.7.3 加载数据库固件

一旦数据库运行,我们就需要一种方法来按需加载数据库固件。数据库固件指的是一组已知或特定的数据集,用于测试。

使用 Jest 加载数据库非常简单,因为我们可以直接通过常规的 MongoDB Node.js 库将数据加载到数据库中。MongoDB 已包含在 example-3 的 package.json 中,你可以这样安装所有依赖:

npm install

如果你是在启动一个新项目,可以这样安装 MongoDB:

npm install --save mongodb

注意,我们使用 --save 而非 --save-dev 参数,因为 MongoDB 不仅在测试中使用,在我们的生产微服务中也需要,因此作为生产依赖安装是必要的。

列表 9.8(摘自 chapter-9/example-3/src/index.test.js)展示了一个简单的函数,用于加载测试数据。我们可以从测试代码中直接调用此函数。只需指定集合名和要加载的数据记录即可。注意,我们是如何通过微服务对象的 db 字段访问数据库的,这在列表 9.6 中显示的变量中已保存,这样我们就不必重复建立数据库连接。

列表 9.8 加载数据库固件的辅助函数

// --省略部分代码--

async function loadDatabaseFixture(collectionName, records) { // 加载数据库固件的辅助函数
  await microservice.db.dropDatabase(); // 重置数据库(切勿在生产环境中使用此方法!)

  const collection = microservice.db.collection(collectionName); // 将测试数据插入数据库
  await collection.insertMany(records);
}

// --省略部分代码--

9.7.4 使用 Jest 创建集成测试

创建集成测试与单元测试在 Jest 中的设置类似,但更为简洁,因为我们不会进行任何模拟操作。

在集成测试中,我们不通过直接调用代码来测试微服务,而是发送 HTTP 请求来触发需要测试的功能。为此,我们可以使用 Node.js 的基础 HTTP 库,就像我们在第 5 章中所做的,或者使用通过 npm 安装的第三方库。在此案例中,我们选择使用 Axios 库,它是一个现代的库,支持 async/await,能够与 Jest 对异步代码的处理能力完美集成。

如果你已经按照 example-3 的 package.json 配置安装了所有依赖项,那么你已经拥有 Axios。如果还未安装,你可以在新项目中这样安装 Axios:

npm install --save-dev axios

在这个示例中,我们使用 --save-dev 参数,因为我们仅在测试中使用 Axios,所以它可以作为开发依赖。然而,如果你计划在生产代码中使用 Axios,请确保使用 --save 参数将其作为常规依赖安装。

列表 9.9(chapter-9/example-3/src/index.test.js)展示了我们的集成测试代码。这与我们的单元测试代码相似,但区别在于我们没有模拟依赖而是实际启动了元数据微服务作为一个真实的 HTTP 服务器。然后我们利用 Axios 发送 HTTP 请求至该服务。

运行列表 9.9 的代码时需要小心,确保不要在生产数据库上执行!加载数据库固件的函数会首先删除整个数据库。请确保你仅在测试数据库上运行这些操作!

列表 9.9 使用 Jest 对元数据微服务进行集成测试

const axios = require("axios");
const mongodb = require("mongodb");

describe("metadata microservice", () => { // 定义元数据微服务的测试套件
  const BASE_URL = "http://localhost:3000"; // 设置正在测试的微服务的基本 URL
  const DBHOST = "mongodb://localhost:27017"; // 设置数据库的 URL,我们在本地运行它
  const DBNAME = "testdb";

  const { startMicroservice } = require("./index");
  
  let microservice;

  beforeAll(async () => { 
    microservice = await startMicroservice(DBHOST, DBNAME); // 启动微服务,包括 HTTP 服务器和数据库连接
  });

  afterAll(async () => { 
    await microservice.close(); // 在测试结束后关闭微服务
  });

  function httpGet(route) { 
    const url = `${BASE_URL}/${route}`;
    return axios.get(url);
  }

  async function loadDatabaseFixture(collectionName, records) { 
    await microservice.db.dropDatabase();

    const collection = microservice.db.collection(collectionName); 
    await collection.insertMany(records);
  }

  test("/videos route retrieves data", async () => { // 测试 /videos 路由能否通过 HTTP 请求检索视频列表
    const id1 = new mongodb.ObjectId();
    const id2 = new mongodb.ObjectId();
    const videoPath1 = "my-video-1.mp4";
    const videoPath2 = "my-video-2.mp4";

    const testVideos = [
      { _id: id1, videoPath: videoPath1 },
      { _id: id2, videoPath: videoPath2 },
    ];

    await loadDatabaseFixture("videos", testVideos); // 加载测试数据到数据库的视频集合中

    const response = await httpGet("/videos"); // 发起 HTTP 请求测试指定路由
    expect(response.status).toEqual(200); // 验证响应状态码

    const videos = response.data.videos;
    expect(videos.length).toEqual(2); // 验证响应数据与测试数据匹配
    expect(videos[0]._id).toEqual(id1.toString());
    expect(videos[0].videoPath).toEqual(videoPath1);
    expect(videos[1]._id).toEqual(id2.toString());
    expect(videos[1].videoPath).toEqual(videoPath2);
  });

  // --省略-- // 可以添加更多测试用例
});

在列表 9.9 中,尽管我们只展示了一个测试,但可以根据微服务的发展轻松添加更多测试。此处我们再次测试了 /videos 路由,但这次是通过其正常的 HTTP 接口,且微服务正在使用一个真实的数据库,而非模拟的数据库。

注意,在列表 9.9 中,我们如何使用 Jest 的 beforeAll 函数在测试开始前启动微服务,并使用 afterAll 函数在测试结束后关闭微服务。我们保存对微服务对象的引用,这意味着我们可以访问其数据库连接并在测试完成时关闭微服务。在以前我们从未需要考虑关闭微服务,但在这里这一步骤至关重要,因为这可能不是唯一的测试套件,我们不希望微服务运行超过必要时间。

你可能已经注意到,当我们为这个测试套件添加更多测试时,我们将对同一个微服务运行多个测试。这种方式共享微服务并不理想,因为它可能影响测试的独立性。但这种方法比为每个测试单独启动和停止微服务要高效得多。我们可以采取措施确保测试套件的可靠性,但这将大大减慢测试套件的执行速度。

其他集成测试微服务的工具

这里还有一些其他工具可用于进行微服务的集成测试,你可以在以后探索:

  • Supertest — 一个与 Express 紧密集成的 API,使发送请求并测试响应变得简单。
  • Pact — 一个更高级的工具,用于测试微服务间的契约(即通信),确保它们之间的交互在将来仍能正常工作。

9.7.5 执行测试

执行集成测试的方法与单元测试相同。使用以下命令启动测试:

npx jest

由于已在 package.json 中配置,你也可以使用:

npm test

尝试运行这些集成测试,看看它们如何工作。此外,尝试修改代码以破坏测试,并观察与单元测试时类似的错误消息。

9.7.6 我们实现了什么?

在本节中,你已经学习了如何使用 Jest 进行集成测试的基本方法。这与单元测试类似,但我们没有进行任何模拟,因此我们在依赖的完整集成环境中运行代码。

在进行集成测试时,我们不会像在单元测试中那样试图隔离代码或模拟依赖,以便单独测试各个部分。我们的目标是在它们的集成环境中测试代码——即测试代码与所有其他依赖(其他模块的代码和外部库的代码)的交互。

从某种意义上说,集成测试因为没有隔离和模拟的复杂性,可能比单元测试更直接。创建集成测试也可能比单元测试更有效率,因为它们通常覆盖更多的代码,从而减少了编写测试所需的总时间。

集成测试的主要缺点是它们通常运行速度比单元测试慢,这是因为它们需要启动整个应用或服务,例如真实的 HTTP 服务器和数据库连接。这也是为什么它们在测试金字塔中处于较高层的原因。

9.8 端到端测试

现在我们来到了测试金字塔(参见图 9.1)的顶层——端到端测试。这与集成测试类似,但目标是在尽可能接近生产环境的配置下测试整个应用程序或其简化版本。

在端到端测试中,我们将测试用户界面——在这个例子中,是 FlixTube 的前端。不需要进行单元测试那样的模拟,这简化了测试过程,但我们需要像集成测试那样使用数据库固件来加载现实的测试数据。

传统上,对分布式应用程序进行端到端测试可能非常复杂,因为配置并启动所有服务通常涉及大量工作。幸运的是,正如你在第 4 章和第 5 章中学到的,现在我们可以通过 Docker Compose 来启动应用程序进行自动化端到端测试,这提供了极大的便利。

在这一节,我们将不再使用 Jest,而是转向使用 Playwright——一个功能强大的工具,用于加载和测试网页。Playwright 拥有众多功能,我们将介绍一些基础知识,让你开始使用并体验其功能。我们将使用 Playwright 通过 FlixTube 的前端网关微服务进行测试,如图 9.9 所示。

图 9.9 使用 Playwright 和 Docker Compose 对整个应用程序进行端到端测试
图 9.9 使用 Playwright 和 Docker Compose 对整个应用程序进行端到端测试

进行端到端测试意味着启动整个应用程序(包括数据库),并测试在 Web 浏览器中运行的前端。这使端到端测试成为所有测试类型中最耗时的,因此它们位于测试金字塔的顶部。

尽管如此,拥有一些端到端测试对于测试策略是至关重要的。端到端测试覆盖了大量的功能,尽管这些测试可能需要较长时间运行,但它们提供了巨大的价值。此外,这种类型的测试通过前端对我们的应用程序进行测试,正是我们客户的视角。毋庸置疑,这是测试应用程序最关键的视角,也是我们非常重视端到端测试的主要原因。

9.8.1 为什么选择 Playwright?

Playwright 是一个全面的网页测试工具,安装和使用都非常简便,它还包含一个自动配置生成器,能够在多种浏览器环境中运行测试,并自动下载所需浏览器。默认情况下,Playwright 在无头模式(headless,即不显示界面)下运行,这使得它在持续集成/持续部署(CI/CD)流程中尤为方便。此外,它还提供了优秀的可视化报告和调试工具。

Playwright 是由微软开发并维护的开源项目。你可以在 GitHub 上找到它的项目代码库: Playwright GitHub

9.8.2 安装 Playwright

在本书中,对于 FlixTube 项目,我们将 Playwright、端到端测试和部分微服务整合到同一个代码库中。当然,你也可以选择不同的结构方式,例如,将 Playwright 测试放在单独的代码库中,或者将前端和测试与其他微服务分开管理。在 example-4 项目中,我们已经包括了所有必要的组件,以便你可以轻松开始使用。

你可以通过以下命令安装所需的依赖:

cd chapter-9/example-4
npm install

对于 example-4 项目,这已足够。但如果你想在新项目中安装 Playwright(按照 Playwright 的官方入门指南),你可以这样做:

npm init playwright@latest

这种安装方式与安装其他 npm 模块略有不同。当然,你也可以选择传统的安装方式:

npm install --save-dev @playwright/test

但如果我们按常规方式安装,我们需要自行配置。使用 npm init 方式安装时,会自动为我们设置配置文件、示例测试,甚至询问我们是否想生成 GitHub Actions 工作流文件,以帮助创建 CI 流程。这为编写端到端测试提供了一个印象深刻的起点。

你可以在图 9.10 中看到安装了 Playwright 的 example-4 项目结构。该结构与我们之前章节中处理的项目相似。我们有一个 docker-compose.yaml 文件来构建和运行应用程序,我们的微服务代码位于相应的子目录中。现在,我们还拥有了一个 Playwright 配置文件、端到端测试脚本以及一个用于加载数据库固件的新 REST API。

图 9.10 安装 Playwright 的 Example-4 项目结构
图 9.10 安装 Playwright 的 Example-4 项目结构

列表 9.10(来自 chapter-9/example-4/playwright.config.js 的摘录)展示了 Playwright 安装期间生成的简化版配置文件(实际文件可能更长)。

列表 9.10 Playwright 的配置文件

const config = {
  testDir: './tests',  // 设置包含测试的项目子目录
  use: {
    baseURL: 'http://localhost:4000', // 设置我们将对其运行测试的网页的基本 URL。这是 FlixTube 前端将运行的位置。
    // --省略其他配置--
  },
  // --更多配置省略--
};

module.exports = config;

注意在列表 9.10 中,我们如何在配置文件中设置基本 URL。这让 Playwright 知道我们的前端(FlixTube 前端)运行的位置,提供了一种便捷方式,使我们在每个测试中访问页面时无需每次都指定完整的 URL。

9.8.3 设置数据库固件

在运行应用程序之前,必须确保可以加载数据库固件。虽然在使用 Jest 时我们可以直接从测试代码中向数据库加载数据,但在 Playwright 环境下,若要保持测试的简洁性,我们最好不直接包含数据库连接和配置代码。相反,我倾向于通过一个单独的 REST API 来管理数据库固件,这样可以在不同项目之间复用,并避免在测试脚本中处理数据库连接的细节。

因此,我们通过一个专用的 REST API 来加载和卸载数据库固件,这意味着我们可以通过 HTTP 请求来管理数据库数据。我们已经使用 Docker Compose,所以在我们的应用架构中添加一个额外的服务并不复杂。图 9.11 显示了包含新数据库固件 REST API 的应用结构。

图 9.11 使用数据库固件 REST API 在使用 Playwright 运行测试之前为数据库植入测试数据
图 9.11 使用数据库固件 REST API 在使用 Playwright 运行测试之前为数据库植入测试数据

创建这样一个 REST API 是一个较大的项目。不过,我已经有了一个过去用于测试项目的 API。我已将其代码包含在 example-4 项目下(可以在 example-4/db-fixture-rest-api 找到)。你也可以在 GitHub 上找到这个项目的独立副本: DB Fixture REST API on GitHub

我们不会在这本书中详细介绍数据库固件 REST API 的内部机制。我们的焦点是设置界限,但你可以自行查看这些代码。放心,它只是一个基于 Express 的 Node.js REST API,与你在本书中看到的其他微服务非常相似。

列表 9.11 是 example-4 的 docker-compose.yaml 文件(chapter-9/example-4/docker-compose.yaml)的摘录,展示了如何将数据库固件 REST API 集成到我们的应用中。

列表 9.11 使用 Docker Compose 集成数据库固件 REST API

version: '3'
services:
  db:
    image: mongo:5.0.9
    container_name: db
    ports:
      - "27017:27017"  # 配置 MongoDB 数据库服务器端口
    expose:
      - "27017"
    restart: always

  db-fixture-rest-api:
    image: db-fixture-rest-api
    build:
      context: ./db-fixture-rest-api  # 指定数据库固件 REST API 的构建上下文
    dockerfile: Dockerfile
    container_name: db-fixture-rest-api
    ports:
      - "9000:80"
    environment:
      - PORT=80
      - DBHOST=mongodb://db:27017
      - FIXTURES_DIR=fixtures
    volumes:
      - ./fixtures:/usr/src/app/fixtures:z
    depends_on:
      - db
    restart: always

列表 9.11 将数据库固件 REST API 添加到我们的应用程序中,但我们还需要一种方法来从 Playwright 测试中调用它,以加载数据库固件。为此,我们将创建一些 JavaScript 函数,这些函数可以在 Playwright 测试中调用以加载数据库固件。列表 9.12(chapter-9/example-4/tests/lib/db-fixture.js 中的摘录)展示了我们可以使用的函数。

列表 9.12 在 Playwright 下加载数据库固件

const axios = require("axios");

const dbFixturesUrl = "http://localhost:9000"; // 数据库固件 REST API 的 URL

async function loadFixture(databaseName, fixtureName) {
  // 先卸载已存在的固件
  await unloadFixture(databaseName, fixtureName);

  const url = `${dbFixturesUrl}/load-fixture?db=${databaseName}&fix=${fixtureName}`; // 构建请求 URL
  await axios.get(url);  // 发送请求以加载固件
}

// 这里省略了其他数据库处理函数。

loadFixture 函数通过向数据库固件 REST API 发出 HTTP GET 请求来加载指定的固件,如例 example-4/fixtures/two-videos/videos.js 中所示。稍后你将看到我们如何从测试代码中调用这个函数。

9.8.4 启动应用程序

已经准备好 Playwright,并可以加载数据库固件。现在,在进行应用测试之前,我们需要启动整个应用程序!

列表 9.11 展示了 example-4 Docker Compose 文件的部分内容。这个配置文件包括了 FlixTube 的简化版本,只涵盖网关和元数据微服务,省略了不需要测试的其他微服务部分。虽然这并不是应用的完整形态,但已足够进行视频列表数据检索及前端显示的测试。

为了本章的简化,我精简了 FlixTube,仅用作一个简洁的示例。但制作应用程序的简化版本是一种很有用的技术,特别是当应用变得庞大而无法在单一计算机上完整运行时。在未来,当我们的微服务应用程序扩展时,可能需要将其切分成更小的、易于测试的部分。这种分割可能是进行端到端测试的唯一方式,尤其是当应用变得复杂且难以管理时。

现在,让我们使用 Docker Compose 启动应用程序:

cd chapter-9/example-4
docker compose up --build

9.8.5 使用 Playwright 编写端到端测试

使用 Playwright 编写端到端测试与使用 Jest 相似,但存在一些差异。列表 9.13(摘自 chapter-9/example-4/tests/frontend.test.js)展示了一个 Playwright 测试示例。注意整体结构由 describetest 函数组成,这很熟悉。看到传递给每个测试的 page 参数了吗?这是 Playwright 提供的一个界面,允许我们操作测试中的网页。

列表 9.13 使用 Playwright 对 FlixTube 进行端到端测试

const { test, expect } = require('@playwright/test'); // 引入 Playwright 测试构造函数。
const { describe } = test;
const { loadFixture } = require('./lib/db-fixture'); // 引入加载数据库固件的函数

describe("flixtube 前端", () => {
  test("能够列出视频", async ({ page }) => {
    await loadFixture("metadata", "two-videos"); // 加载两个视频的固件到元数据数据库集合中
    await page.goto('/'); // 访问 FlixTube 网页(基本 URL 在 Playwright 配置文件中设置)

    const videos = page.locator("#video-list > div"); // 定位页面中的视频元素(HTML divs)
    await expect(videos).toHaveCount(2); // 验证是否在 UI 中显示了两个视频

    const firstVideo = videos.nth(0).locator("a");
    await expect(firstVideo)
      .toHaveText("SampleVideo_1280x720_1mb.mp4"); // 检查第一个视频的显示详情
    await expect(firstVideo).toHaveAttribute("href",
      "/video?id=5ea234a1c34230004592eb32");

    const secondVideo = videos.nth(1).locator("a");
    await expect(secondVideo)
      .toHaveText("Another video.mp4"); // 检查第二个视频的显示详情
    await expect(secondVideo).toHaveAttribute("href",
      "/video?id=5ea234a5c34230004592eb33");
  });

  // 更多测试可以在此处添加。
});

测试的第一步是调用 loadFixture 函数,然后使用 page.goto 让 Playwright 访问网页。其他的 Playwright 函数都是基于访问的页面进行操作的。这里我们访问 FlixTube 网站的根路由 /。请注意这个 URL 是相对于我们在 Playwright 配置文件中设置的基本 URL 的。

我们接着使用 page.locator 来检索浏览器的文档对象模型(DOM)中的元素并对它们进行测试。代码检查视频列表中是否有两个视频,并验证每个视频的名称和链接。由于我们已经为元数据微服务的数据库加载了 two-videos 固件,这些视频应当在前端显示。

现在,让我们运行我们的测试:

npx playwright test

你可以在终端中看到 Playwright 显示的测试进展和正在运行的浏览器。如果有失败,Playwright 会提供反馈。

如果你偏好在终端看到所有测试结果(通过或失败),可以使用以下命令列出测试结果:

npx playwright test --reporter=list

确保在尝试测试之前应用程序已经启动。如果你还没有启动应用程序,你将没有任何应用程序可用于运行测试:

docker compose up --build

端到端测试可能需要一些时间来完成。这是因为需要启动一个真实的网页浏览器来执行测试(即使在无头模式下运行)。这些测试甚至更慢,因为 Playwright 默认配置在三个不同的浏览器(Chrome、Firefox 和 Safari)上运行测试。这是一个强大的功能,允许我们在多个浏览器上轻松地运行自动化测试。

在示例 4 中,我们预期的测试应该可以顺利通过(你将看到三个成功的测试结果,每种浏览器一个)。如果你愿意,现在可以尝试破坏这些测试,类似于我们之前对 Jest 测试所做的。比如,你可以打开文件 example-4/gateway/src/views/videolist.hbs,这是用于渲染 FlixTube 首页的 HTML 文件(采用 Handlebars 模板格式,我们将在第 10 章详细讨论)。尝试更改这些 HTML,使列表中的每个视频都显示不同的内容。再次运行测试,你会发现测试结果已经受到影响。

重要提醒:绝对不要在生产数据库上执行这些测试。加载数据库夹具会清空相关的数据库集合,即将其重置为我们的测试数据,这可能导致生产数据的丢失。事实上,我们不应该在生产环境中运行带有数据库夹具的 REST API 或将其连接到生产数据库!这种做法使我们可以加载数据库夹具,但仅限于开发和测试环境中使用。如果不小心将数据库夹具 REST API 连接到了生产数据库,你可能会造成严重错误(例如删除客户数据)。

注意 在生产环境中运行数据库夹具 REST API 也会使你的数据库面临外部访问的风险。这是引发灾难的风险因素,因此绝对不要在生产环境中实例化它。

通过 Playwright,我们能够执行更多操作,例如点击按钮,或者在列表 9.13 中点击第一个视频元素:

await firstVideo.click();

或者在输入字段中输入内容:

anInputElement.type("Hello world");

想要了解更多操作方法,请访问 Playwright 官方文档: Playwright Docs

Playwright 的视觉跟踪功能特别有趣,它为我们提供了一个 GUI,可以步骤化、可视化地查看每个测试动作: Trace Viewer Introduction 。Playwright 对测试调试也提供了很好的支持: Debugging Tests

我们还可以利用 Playwright 模拟后端和 REST API,这对于对前端进行单元测试非常有用。这意味着我们的测试不再是完全的端到端测试,但它为前端的隔离测试提供了极大的价值。请在此处阅读更多信息: Mocking

9.8.6 使用 npm 运行 Playwright

现在,我们可以设置使用 npm 来调用 Playwright 测试,就像我们之前使用 Jest 所做的那样。示例 4 是本章中其他示例的一个独立项目,我们在此使用不同的测试工具(Playwright 而不是 Jest)。尽管如此,我们希望能通过常规的 npm test 脚本运行 Playwright 测试,如下所示:

npm test

列表 9.14 展示了我们在 package.json 中进行的设置,以便实现这一功能。我们已经配置了“test”脚本来运行我们的 Playwright 测试。

列表 9.14 配置 npm 脚本以运行 Playwright 的 package.json

{
  "name": "example-4",
  "version": "1.0.0",
  "scripts": {
    "test": "playwright test —reporter=list —workers 1" // 使用单个工作进程运行我们的 Playwright 测试。因为我们在所有测试中共享一个数据库,我们需要这样做。要在多个工作进程上并行运行我们的测试,我们需要模拟我们的后端或数据库。
  },
  "dependencies": {},
  "devDependencies": {
    --snip--
  }
}

目前还无法在 watch 模式下运行 Playwright,因此我们不能像使用 Jest 那样实现 npm script test:watch。但这种情况可能会在未来改变,因为 Playwright 的开发还在继续进行。

9.8.7 我们达到了什么成就?

我们几乎探索完了测试领域。我们介绍了单元测试、集成测试,以及现在的端到端测试。

我们讨论了不同测试的相对性能:集成测试比单元测试慢,端到端测试又比集成测试慢。我们还看到,每个单元测试只覆盖了一小块孤立的代码。集成测试和端到端测试非常有效,因为它们可以用更少的测试覆盖更多的代码。

现在的问题是,你应该有多少种类型的测试?这个问题没有确切的答案,因为它取决于每个项目的具体情况。

但我可以说,你可能并且应该拥有成百上千的单元测试。你需要的集成测试数量会相对较少,端到端测试的数量则更少。具体需要多少,实际上取决于你愿意等待一个测试运行完成的时间。如果你乐意在周末或者一夜之间等待测试套件完成,那么你可能可以承受拥有成百上千的端到端测试。

然而,作为开发人员,我们渴望快速而全面的反馈。对此,我们无法超越单元测试。如果我们可以通过许多极快的单元测试覆盖大量代码,那么这就是我们应该追求的,因为快速的测试会被开发团队所接受,而慢速的测试则不会(开发者讨厌等待测试)。如果你的测试很慢,开发者可能会避开它们。

归根结底,这不是非黑即白的问题。甚至没有明确区分不同类型的测试。单元测试结束与集成测试开始的地方并不明显。所有测试都处于一个连续的区间上,这是一个有许多灰色阴影的区间。

9.9 自动化测试在 CI/CD 流程中的应用

我们已经掌握了一套自动化测试。现在,让我们探讨自动化测试的真正价值:实现全自动化!为了达到真正的自动化,我们的测试应直接在托管的代码库上运行。当开发人员将代码更改推送到代码库时,我们希望自动触发测试套件,以验证代码的健康状况。要做到这一点,我们必须将测试集成进 CI/CD 流程中,确保每次代码推送时,测试能够自动执行。自动化测试在生产部署前作为一个验证点尤其重要,如图 9.12 所示。如果测试通过,则代码将被部署到生产环境。如果测试失败,则代码不会部署到生产环境。

图 9.12 在 CD 流程中的自动化测试
图 9.12 在 CD 流程中的自动化测试

我们之前讨论在 package.json 中配置 npm test 脚本的原因是,这是一种将自动化测试集成到 CI/CD 流程中的有效方法。将自动化测试纳入 CI/CD 流程相当简单;只要项目配置正确,我们就可以执行以下命令:

npm test

你可能还记得我们在第 8 章已经使用过这个命令。回顾清单 8.3,你可以看到 npm test 如何被集成到 GitHub Actions 工作流中。

9.10 测试概述

在结束本章之前,表 9.2 提供了对 Jest 和 Playwright 及其测试执行方式的快速回顾。

表 9.2 测试命令概述

命令描述
npx jest --init初始化 Jest 配置文件。
npx jest运行 Jest 下的所有测试。
npx jest --watch开启实时重新加载功能,当代码变更时自动重新运行测试。它通过 Git 判断哪些文件已变更。
npx jest --watchAllnpx jest --watch 类似,但它监控所有文件的变更,而不仅是 Git 报告已变更的文件。
npx playwright test运行 Playwright 测试,并在我们的网页浏览器中打开测试报告。
npx playwright test --reporter=list以列表形式在终端中展示 Playwright 测试结果。
playwright test --workers 1仅使用一个工作线程来运行 Playwright 测试。这将禁用并行执行,适用于测试共享数据库的情况。
npm test运行测试的 npm 脚本约定,可以执行 Jest 或 Playwright 测试(如果配置得当,甚至可以同时运行两者)。这是在 CI/CD 流程中执行测试套件的命令。
npm run test:watch这是我个人喜欢的命令,用于在实时重新加载模式下运行测试。要使用它,你需要在 package.json 文件中配置这个脚本。

9.11 继续学习

本章你已经学到了自动化测试的基础知识。尽管这里的内容足以让你启动自己的测试方案,但请记住,测试是一个庞大而专业的领域。想要进一步深入学习,请参考以下书籍:

  • 由 Vladimir Khorikov 著作的《Unit Testing Principles, Practices, and Patterns》(Manning 出版社,2020 年出版)
  • Roy Osherove 和 Vladimir Khorikov 著作的《The Art of Unit Testing》第三版(Manning 出版社,预计 2024 年 2 月出版)
  • Alex Soto Bueno、Andy Gumbrecht 和 Jason Porter 著作的《Testing Java Microservices》(Manning 出版社,2018 年出版)
  • Brandon Byars 著作的《Testing Microservices with Mountebank》(Manning 出版社,2018 年出版)

还可以查看 Elyse Kolker Gordon 著作的《Exploring JavaScript Testing》(Manning 出版社,2019 年出版),这是一本关于测试的章节合集,可从 Manning 官网免费获取:

要了解更多关于 Jest 的信息,请访问 Jest 网页和入门指南:

要了解更多关于 Playwright 的信息,请访问 Playwright 网页和入门指南:

总结

  • 自动化测试对于扩展到大量微服务并确保它们都正常工作至关重要。
  • 测试金字塔图显示了单元测试、集成测试和端到端测试之间的关系,通常应该有更多的单元测试,其次是集成测试,最后是端到端测试。
  • 单元测试旨在测试隔离代码中的小单元(比如一个函数或一个函数的一个方面)。隔离测试意味着我们只测试我们关心的代码,其他代码不会干扰测试结果。
  • 模拟(Mocking)意味着创建假的或模拟的依赖项以隔离我们的代码。
  • 在 JavaScript 中模拟特别容易,因为我们可以轻松创建和配置新对象和新函数。这使得在 JavaScript 中进行测试非常愉快(至少在我看来是这样)。
  • 集成测试测试较大部分的代码,比如整个微服务。
  • 端到端测试测试我们的整个应用程序或它的一个精简或有限配置(例如,当我们的微服务应用程序太大而无法在一台计算机上运行时,或者当我们想测试它的特定部分时)。
  • 我们可以使用 Jest 测试框架运行单元和集成测试,这是一个流行的 JavaScript 测试工具。
  • 我们可以通过启动整个微服务并通过其正常接口触发其功能来为微服务执行集成测试。我们的测试代码可以通过 HTTP 请求或消息(例如 RabbitMQ 消息)调用微服务。
  • 我们可以通过使用 Docker Compose 启动应用程序(或其某个有限部分)并使用 Playwright 测试框架通过前端与应用程序交互来为微服务应用程序执行端到端测试。
  • 可以使用 Jest 的 –watch 和 –watchAll 标志启用实时重新加载,这样我们在编码时会自动重新运行我们的自动化测试,从而在开发过程中提供快速反馈。
  • 我们的测试可以集成到我们的 CI 或 CD 管道中,以创建一个自动警告系统,当我们的代码出问题时通知我们。通常,通过在 package.json 文件中实现“test”脚本,然后在 CI/CD 管道中调用 npm test 命令来集成 JavaScript 测试。

文章导航

独立页面

这是书籍中的独立页面。

书籍首页

评论区