【读书笔记】《Go With The Domain》11. 测试架构
📔【读书笔记】《Go With The Domain》11. 测试架构
2023-10-4
| 2024-10-15
0  |  0 分钟
type
status
date
slug
summary
tags
category
icon
password
Sub-item
Last edited time
Oct 15, 2024 09:10 AM
Parent item
领域
您是否知道当您从头开始开发新应用程序,并可以通过适当的测试覆盖所有流程时的罕见感觉?
我说“罕见”是因为大多数时候,您将使用历史悠久、贡献者众多且测试方法不那么明显的软件。即使代码使用了好的模式,测试套件也并不总是遵循。
有些项目没有设置现代化的开发环境,因此只有易于测试的东西才进行单元测试。例如,他们单独测试单个功能,因为很难测试公共 API。团队需要手动验证所有更改,可能是在某种临时环境中。您知道当有人引入更改但不知道他们需要手动测试时会发生什么。
其他项目从一开始就没有测试。它可以通过走捷径来加快开发速度,例如将依赖关系保持在全局状态。当团队意识到缺乏测试会导致错误并减慢它们的速度时,他们决定添加它们。但现在,想要合理地做到这一点是不可能的。因此,团队编写了一个具有适当基础设施的端到端测试套件。
端到端测试可能会给您一些信心,但您不想维护这样的测试套件。它很难调试,即使是最简单的更改也需要很长时间来测试,并且发布应用程序需要几个小时。在这种情况下引入新的测试也不是小事,因此开发人员尽可能避免它。
我想介绍一些迄今为止对我们有用的想法,应该可以帮助您避免上述情况。
本章不是关于哪个测试库最好或者可以使用哪些技巧(尽管我将展示一些技巧)。它更接近我所说的“测试架构”。这不仅涉及“如何”,还涉及“在哪里”、“什么”和“为什么”。
关于不同类型的测试有很多讨论,例如“测试金字塔”(Robert 在高质量数据库集成测试中提到过)。这是一个值得记住的有用模型。然而,它也是抽象的,你无法轻易测量它。我想采取更实用的方法,展示如何在 Go 项目中引入几种测试。

为什么要为测试烦恼呢?

但是测试代码难道不像应用程序的其余部分那么重要吗?难道我们不能接受保持测试良好状态是困难的事实并继续前进吗?不是可以加快开发速度吗?
如果您一直关注本系列,您就会知道我们的所有章节都基于 Wild Workouts 应用程序。
当我开始编写本章时,本地运行测试对我来说甚至无法正常工作,而且这是一个相对较新的项目。
发生这种情况的原因之一是:我们没有在 CI 管道中运行测试。
这令人震惊,但似乎即使是使用最流行、最前沿技术的无服务器、云原生应用程序也可能是混乱中伪装出来的。
我们知道现在应该向管道添加测试。众所周知,这让您有信心将更改安全地部署到生产中。然而,这也是有代价的。
运行测试可能会占用管道持续时间的很大一部分。如果您没有以与应用程序代码相同的质量来设计和实现它们,您可能会意识到得太晚了,管道需要一小时才能通过,并且会随机失败。即使您的应用程序代码设计良好,测试也可能成为交付更改的瓶颈。

分层

我们对该项目进行了几次重构。我们引入了像 Repository这样的模式。通过关注点分离,我们可以更轻松地推理项目的特定部分。
让我们回顾一下在前面的章节中介绍过的分层的概念。如果您之前没有机会阅读这些内容,我建议您在继续之前阅读这些内容 - 这将帮助您更好地理解本章。看一下图表可以帮助我们理解项目的结构。以下是使用 Wild Workouts 中使用的方法构建的通用服务
整体框架
整体框架
所有外部输入都从左侧开始。应用程序的唯一入口点是通过端口层(HTTP 处理程序、Pub/Sub 消息处理程序)。端口在应用层执行相关处理程序。其中有些会调用Domain代码,有些会使用Adapter,这是退出服务的唯一途径。适配器层是数据库查询和 gRPC 客户端所在的位置。
下图显示了 Wild Workouts 中部分trainer服务的层次和流程。
trainer服务
trainer服务
现在让我们看看需要哪些类型的测试来涵盖所有内容。

单元测试

我们从内层和每个人都熟悉的东西开始:单元测试。
notion image
领域层是服务中最复杂的逻辑所在的地方。然而,这里的测试应该是一些最简单的编写和运行速度超快的测试。领域中没有外部依赖项,因此您不需要任何特殊的基础设施或模拟(除了非常复杂的场景,但我们暂时保留它)。
根据经验,您应该瞄准领域层的高测试覆盖率。确保仅测试导出的代码(黑盒测试)。将 _test 后缀添加到包名称中是强制执行此操作的一个很好的做法。
领域代码是纯逻辑且易于测试,因此它是检查所有极端情况的最佳位置。表驱动测试对此尤其有用。
我们离开领域,进入应用层。引入 CQRS 后,我们将其进一步分为命令和查询。
根据您的项目,可能不需要测试任何内容或需要涵盖一些复杂的场景。大多数时候,尤其是在查询中,此代码只是将其他层粘合在一起。测试这个不会增加任何价值。但如果命令中有任何复杂的编排,那么这就是单元测试的另一个好例子。
💡
注意应用程序层中的复杂逻辑。如果您在这里开始测试业务场景,那么值得考虑引入领域层。 另一方面,它是编排的完美场所——以特定顺序调用适配器和服务并传递返回值。如果程序像之前那样分层,那么每次更改领域代码时应用程序测试都不应该受影响。
与领域代码相反,应用程序的命令和查询中有许多外部依赖项。如果您遵循 Clean Architecture,那么很容易对他们进行mock。在大多数情况下,具有单个方法的结构可以实现完美的mock。
💡
注意:如果您更喜欢使用mocking库或由代码生成的mock代码,您也可以使用它们。 Go 允许您定义和实现小型接口,因此我们选择自己定义模拟,因为这是最简单的方法。
下面的代码片段展示了如何使用注入的mock创建应用程序命令。
请注意添加不检查任何相关内容的测试,这样您就不会最终测试模拟。如果没有业务逻辑,就完全跳过测试。我们已经覆盖了两个内层。我想到目前为止这似乎并不新鲜,因为我们都熟悉单元测试。然而,持续交付成熟度模型仅将它们列为“基本”成熟度级别。现在让我们看看集成测试。

集成测试

阅读此标题后,您是否想象过一个需要重试多次才能通过的长时间运行的测试?集成测试没有理由缓慢且不稳定。自动重试和增加睡眠时间等做法应该是绝对不可能的。
在我们的上下文中,集成测试是检查适配器是否与外部基础设施正常工作的测试。大多数时候,这意味着测试数据库存储库。
这些测试不是检查数据库是否正常工作,而是检查你是否正确使用它(集成部分)。这也是验证您是否知道如何使用数据库内部结构(例如处理事务)的绝佳方法。
notion image
因为我们需要真正的基础设施,所以集成测试的编写和维护比单元测试更具挑战性。通常,我们可以使用 docker-compose 来启动所有依赖项。
💡
注意:我们是否应该使用 Docker 版本的数据库来测试我们的应用程序? Docker 镜像几乎总是与我们在生产环境中运行的镜像略有不同。在某些情况下,例如 Firestore,只有模拟器可用,而不是真正的数据库。 事实上,Docker 并不能反映您在生产环境中运行的确切基础设施。然而,你更有可能搞乱代码中的 SQL 查询,而不是因为细微的配置差异而偶然发现问题。 一个好的做法是将镜像版本固定为与在生产环境中运行的版本相同。使用 Docker 不会为您提供 100% 的生产同等性,但它消除了“在我的机器上运行”问题并使用适当的基础设施测试您的代码。 Robert 在高质量数据库集成测试中深入介绍了数据库的集成测试。

保持集成测试稳定和快速

当处理网络调用和数据库时,测试速度变得非常重要。并行运行测试至关重要,在 Go 中可以通过调用 t.Parallel() 启用并行测试。这看起来很简单,但我们必须确保我们的测试支持这种行为
例如,考虑这个简单的测试场景:
  1. 检查数据库中trainings集合是否为空
  1. 调用添加训练的存储库方法
  1. 检查集合中是否有一项训练
如果另一个测试使用相同的集合,您将因竞争条件而随机失败。有时,该集合将包含多个我们刚刚添加的训练。
最简单的方法是永远不要断言列表长度之类的东西,而是检查它是否是我们正在测试的确切东西。例如,我们可以获取所有训练,然后迭代列表以检查是否存在预期的 ID。
另一种方法是以某种方式隔离测试,这样它们就不会互相干扰。例如,每个测试用例都可以在唯一用户的上下文中工作(请参阅下面的组件测试)。
当然,这两种模式都比简单的长度断言更复杂。当您第一次偶然发现这个问题时,可能很容易放弃并决定“我们的集成测试不需要并行运行”。不要这样做。有时你需要发挥创造力,但最终并不需要付出太多努力。作为回报,您的集成测试将稳定且运行得与单元测试一样快。
如果您在每次运行之前创建了一个新数据库,这也是可以保证测试不相互干扰。
💡
警告!在迭代测试用例时,有些常见但难以发现的问题。 在使用表驱动测试时,您经常会看到如下代码:
这是对一部分测试用例运行测试的惯用方法。假设您现在想要并行运行每个测试用例。解决方案看起来很简单:
遗憾的是,这不会按预期工作。 添加并行开关使父测试函数不再等待 t.Run 生成的子测试。因此,您无法在 func 闭包内安全地使用 c 循环变量。像这样运行测试通常会导致所有子测试都适用于最后一个测试用例,而忽略所有其他测试用例。 最糟糕的部分是测试将通过,并且在使用 -v 标志运行 go test 时您将看到正确的子测试名称。注意到这个问题的唯一方法是更改期望测试失败的代码并看到它们通过。正如 wiki 中提到的,解决此问题的一种方法是引入一个新的作用域变量:
这只是一个品味问题,但我们不喜欢这种方法,因为对于那些不知道这意味着什么的人来说,它看起来像是某种魔咒。相反,我们选择更冗长但明显的方法:
即使您了解这种行为,也很容易滥用它,非常危险。更糟糕的是,似乎流行的 linter 默认情况下不会检查这一点 - 如果您知道做得很好的 linter,请在评论中分享。我们在 Watermillb 库中犯了这个错误,导致一些测试根本无法运行。您可以在此提交中看到修复
我们通过测试覆盖了数据库存储库,但我们还有一个 gRPC 客户端适配器。我们应该如何测试这个呢?
在这方面它与应用层类似。如果您的测试会重复它检查的代码,那么添加它可能没有意义。更改代码时只会增加额外的工作。让我们考虑一下用户服务 gRPC 适配器:
这里没有什么有趣的东西可以测试。我们可以注入一个模拟客户端并检查是否调用了正确的方法。但这不会验证任何内容,并且代码中的每个更改都需要在测试中进行相应的更改。

组件测试

到目前为止,我们已经为应用程序的隔离部分创建了大部分狭窄的、专门的测试。此类测试非常适合检查极端情况和特定场景,但这并不意味着每个服务都能正常工作。很容易忘记从端口调用应用程序处理程序。此外,单独的单元测试并不能帮助我们确保应用程序在重大重构后仍然可以工作。
现在是对我们所有服务运行端到端测试的时候了吗?还没有。
由于没有调用测试类型的标准,我鼓励您遵循 Simon Stewart 的建议。创建一个表格,让团队中的每个人都清楚地了解特定测试的期望。然后,您可以删除有关该主题的所有(无成效的)讨论。在我们的例子中,该表可能如下所示:
notion image
为了确保每个服务在内部正常工作,我们引入了组件测试来检查所有层的协同工作。组件测试涵盖与应用程序中的其他服务隔离的单个服务。
我们将调用真正的端口处理程序并使用 Docker 提供的基础设施,还将模拟所有到达外部服务的适配器。
notion image
您可能想知道,为什么不测试外部服务呢?毕竟,我们可以使用 Docker 容器并一起测试它们。
问题在于测试多个连接服务的复杂性。如果你只有几个,那就足够了。但请记住,您需要为启动的每个服务配备适当的基础设施,包括它使用的所有数据库和它调用的所有外部服务。它很容易总共有数十个服务,通常由多个团队拥有。
我们将在接下来的端到端测试中讨论这个问题。但现在,我们添加组件测试,因为我们需要一种快速的方法来了解服务是否正常工作。
notion image
我们可以在不需要集成环境的情况下完成大部分测试。我们可以并且确实独立于它所依赖的其他应用程序/服务来部署或发布我们的应用程序。
等下,微服务不是应该解决团队相互依赖的问题吗?如果您认为在您所处理的应用程序中不可能实现这一目标,则可能是因为架构选择不佳。您可以通过应用我们计划在未来章节中介绍的战略 DDD 模式来修复它。 我们在本系列中提出了这一点:使用微服务并不会让您的应用程序和团队本身减少耦合。解耦需要对应用程序架构和系统级别进行有意识的设计。在组件测试中,我们的目标是单独检查单个服务及其所需的所有基础设施的完整性。我们确保服务接受我们同意的 API 并以预期结果进行响应。
这些测试在技术上更具挑战性,但仍然相对简单。我们不会运行真正的服务二进制文件,因为我们需要模拟一些依赖项。我们必须修改启动服务的方式才能使其成为可能。
💡
注意 再次强调,如果您遵循依赖倒置原则(只是提醒一下,它是 SOLID 的一部分),那么在服务级别注入模拟应该是微不足道的。
我为我们的 app.Application 结构引入了两个构造函数,它保存所有命令和查询。第一个的工作方式与以前一样,设置真正的 gRPC 客户端并注入它们。第二个用模拟代替它们。
我们现在可以简单地在单独的 goroutine 中运行该服务。
我们只想为所有测试运行一个服务实例,因此我们使用 TestMain 函数。这是在运行测试之前插入设置的简单方法。
我创建了 WaitForPort 辅助函数,该函数会等待指定端口打开或超时。这很重要,因为您需要确保服务已正确启动不要用睡眠代替它。您要么添加太多的延迟,使测试变慢,要么让它太短,并且它会随机失败。
组件测试要测试什么?通常测试正常场景就足够了。不要检查那里的极端情况。单元和集成测试应该已经涵盖了这些。确保处理正确的有效负载、存储正常且响应正确。

调用端口

我使用 openapi-codegen 生成的 HTTP 客户端。与服务器部分类似,这使得编写测试变得更加容易。例如,您不需要指定整个 REST 路径,也不需要每次都序列化JSON。
尽管生成的客户端为我们节省了大量样板文件,但我仍然添加了带有客户端包装器的tests/client.go 文件以用于测试目的。
测试变得更具可读性,并且很容易理解发生了什么。让测试通过很容易,但在代码审查中理解它们、阅读所有断言和模拟要困难得多。
我们现在可以使用一行来清楚地说明正在发生的事情,而不是上面的代码片段。
其他辅助方法有 FakeAttendeeJWTFakeTrainerJWT。他们使用所选角色生成有效的授权token。由于 gRPC 使用从 protobuf 生成的结构,因此客户端已经很容易使用。

端到端测试

最后,我们来到测试套件中最可怕的部分。我们现在将把所有模拟抛在脑后。
端到端测试验证您的整个系统协同工作。它们速度慢、容易出错并且难以维护。您仍然需要它们,但请确保将它们构建得很好。
在 Wild Workouts 中,它与运行组件测试类似,只是我们将启动 docker-compose 内的所有服务。然后,我们将验证一些仅调用 HTTP 端点的关键路径,因为这是我们向外部世界公开的内容。
💡
注意:如果您无法在 docker-compose 上运行整个平台,则需要找到类似的方法。如果您已经在使用它,它可能是一个单独的 Kubernetes 集群或命名空间,或者某种临时环境。
notion image
现在到了问题多于答案的部分。您应该在哪里进行端到端测试?哪个团队应该拥有它?在哪里运行它们?多频繁?它们应该是 CI/CD 管道的一部分还是从 cronjob 运行的单独的东西?
我无法给你明确的答案,因为这在很大程度上取决于你的团队结构、组织文化和 CI/CD 设置。与大多数挑战一样,尝试一种看起来最好的方法并迭代它,直到您满意为止。
我们很幸运,整个应用程序只有三个服务,所有服务都使用相同的数据库。随着服务和依赖项数量的增加,您的应用程序将变得更难以以这种方式进行测试。
尽量保持端到端测试简短。他们应该测试服务是否正确连接在一起,而不是测试它们内部的逻辑。这些测试起到双重检查的作用。大多数时候他们不应该失败,如果失败,通常意味着有人违反了合同。
💡
请注意,我说过我们将仅使用 HTTP 端点,因为这是公开公开的。有一个例外:我们通过 gRPC 调用用户服务来为测试参与者添加训练课时余额。正如您所期望的,公众无法访问此端点。
该代码与我们在组件测试中所做的非常接近。主要区别是我们不再在单独的 goroutine 中启动服务。相反,所有服务都使用我们在生产中部署的相同二进制文件在 docker-compose 内运行。

验收测试

您经常会发现验收测试被定义为单元测试和集成测试之后的下一个级别。我们认为它们与测试的技术方面是正交的。这是一个专注于完整业务功能而不是实现细节的测试。正如 Martin Fowler 所说:
事情是这样的:在某一时刻,您应该确保从用户的角度(而不仅仅是从技术角度)测试您的软件是否正常工作。你所说的这些测试实际上并不那么重要。然而,进行这些测试是:选择一个术语,坚持使用它,然后编写这些测试。
在我们的例子中,组件测试和端到端测试都可以被视为验收测试。如果您愿意,您可以对其中一些使用 BDD样式 - 这使它们更易于阅读,但添加了一些样板文件。
notion image

我们现在可以睡个好觉了吗?

除非我们在生产中破坏服务,否则它并没有真正经过测试。
可靠的测试套件将捕获大部分错误,以便您能够一致地交付。但无论如何,您都希望为停电做好准备。我们现在进入监控、可观测性和混沌工程的主题。不过,这不在今天的讨论范围之内。
请记住,没有任何测试套件可以给您完全的信心。拥有一个允许快速回滚、恢复和撤消迁移的简单流程也至关重要。

继续

如果您查看完整的 commit,您可能会注意到我们现在注入依赖项的方式不是很优雅。我们将在未来迭代这一点。我们已经有了测试套件,但我们仍然错过了一个关键部分:它没有在我们的持续集成管道中运行。此外,为测试提供适当的 docker-compose 和环境变量也并非易事。当前的解决方案有效,但如果您不知道发生了什么,则不容易理解。在自动化构建中运行测试只会使其变得更加复杂。我们将在以后的章节中介绍这些主题。
架构设计
  • 读书笔记
  • 技术架构
  • 【Git】Git/Github常用用法[转载]日志:每个软件工程师都应该知道的有关实时数据的统一抽象
    目录