想在不建立完整测试环境的情况下测试微服务?
想在将变更推送到主线分支之前完成测试?
这是我们在进行项目交付时经常遇到的难题。最近,当我们开始一个新的项目,为客户构建一个新的聚合平台时,我们希望将尽可能多的测试和自动化转移到流程的合并前执行。
我们知道,我们需要一种方法来对我们的服务执行深度功能测试,并使我们的团队能够独立运行。这就是促使我们开发用于微服务隔离和测试的测试脚手架(Test Scaffolding)的原因。
挑战
在部署到集成环境之前,我们需要独立于其他服务对微服务的变更进行测试。这样,当我们将变更部署到上层环境时,就可以专注于测试集成和事务流,而不是单个服务的功能行为。
虽然我们可以将测试中的服务指向其依赖的测试环境,但这可能会导致两个问题。
首先,环境用户之间的数据混合会导致意想不到的行为。
其次,由于服务之间的连锁调用,可能会出现延迟问题和测试缓慢。我们很快意识到,我们需要在合并前隔离服务的能力,以实现集中和完全隔离的功能测试自动化。
方法
我们决定通过引入一个名为测试脚手架Test Scaffolding的概念来隔离服务。
测试脚手架的工作是模拟微服务可能需要与之交互的所有外部服务。测试脚手架的作用与建筑脚手架的作用类似,随着微服务交互的增加,会将其添加到脚手架中。每个服务都有自己的脚手架,与服务预合并(the service pre-merge)一起构建和部署。
这样就为我们的微服务提供了它所期望的依赖关系,并通过测试对输入和输出进行细粒度控制。
这种方法对于独立构建服务并向我们的工程团队提供持续的早期反馈也至关重要。
微服务基础知识
我们将在下面的示例中使用 AWS 术语,但这些概念应适用于所有平台。
开始之前,我们必须对如何定义与测试脚手架交互的功能测试范围有一个共同的理解:
测试仅限于特定微服务的边界。
测试已部署服务的交互属于我们的行程测试(journey tests)。
在项目中,我们将微服务的边界定义为服务CloudFormation Template(CFT)所定义的组件。
微服务中最常见的组件通常是API Gateway、Lambdas、Dynamo 和 SNS 或 SQS 的组合,我们可以用这样的图来表示:
在这个例子中,假设我们需要测试微服务A,微服务A在给定的数据流中与微服务B和C通信。由于这些服务的目的非常集中,因此它们必须联系其他服务来验证外部信息。
微服务A与微服务B 、C对话
我们不想让B和C的实例来测试A,所以我们需要隔离一些交互:
挑战:将A与B和C隔离
这正是测试脚手架的作用。它取代了服务B和C的位置,在测试中提供了大量关于如何设置和管理输入和输出的控制。
有几点需要注意:
每个服务只有一个测试脚手架。我们可以根据需要在单个测试脚手架中添加多种不同类型的交互--即使它们代表不同服务的不同端点。
微服务和测试脚手架都由各自的 CloudFormation 模板(CFT)定义,但存储在同一个项目 repo 中。
使用案例示例:
既然我们已经定义了微服务的边界,那就来讨论几个用例吧。
在第一个示例中,服务接受 API 请求,处理请求,然后通过 SNS 主题发送输出。我们在项目中使用这种模式将信息发送到不同的聚合器服务,这些聚合器服务会将数据映射成正确的格式,然后发布到多个第三方服务。
我们要确保输入值得到正确处理,并输出到 SNS 主题。与 SNS 交互非常棘手,因为它需要订阅才能捕获响应。这很难在自动测试中即时完成,因此我们添加了一个 Lambda 来处理对 SNS 主题的订阅,并添加了 DynamoDB 来保留响应,以便对测试脚手架进行验证。
这样,测试就可以向服务发送请求,让服务处理请求,然后从脚手架 DynamoDB 中提取发送到 SNS 主题的结果,以确定结果是否符合我们的预期。
第二个示例与第一个示例类似。在这里,我们的微服务从 SNS 主题获取事件,并调用服务进行额外的状态验证,例如产品是否处于活动状态。然后,该状态将发布到映射的外部服务。
在这种情况下,我们的测试脚手架将包含 SNS 主题、API 网关、Lambda 和 Dynamo。测试会将我们希望服务从测试脚手架 API 调用中接收的数据填充到 Dynamo 中,并触发 SNS 主题流以启动测试。在处理过程中,微服务会将其状态调用发送到测试脚手架中的 API 网关,并获取测试在此暂存的响应。我们在微服务的 DynamoDB 中验证流程的最终输出。
如示例所示,使用测试脚手架可以让我们高度灵活地匹配服务的交互要求,从而可以完全隔离地测试每个服务。
脚手架设置、部署和执行
那么,我们是如何配置这些服务从而确保它们在正确的区域内运行的呢?
为了取得成功,我们在云组建模板中进行了参数化。测试脚手架和被测微服务都有各自的 CFT 来管理必要的资源。在微服务 CFT 中,我们利用模板参数化和条件语句来控制测试脚手架的使用,具体取决于服务的部署方式。这些参数通过 CI/CD 管道传入。在我们的预合并部署中,这可以让微服务寻找测试脚手架,而在集成环境中,我们可以让该服务寻找适当的已部署服务。
下面是根据输入参数有条件使用测试脚手架的微服务 CFT 片段:
Conditions:
condIsProd: !Equals [ !Ref paramEnvironment, prod ]
condNotFeatureBranch: !Equals [!Ref paramFeatureBranch, ""]
condIsLocal: !Equals [ !Ref paramEnvironment, local ]
condIsLocalOrBranch: !Or [ !Not [ Condition: condNotFeatureBranch ], Condition: condIsLocal ]
Resources:
resLambdaConvertPicture:
Type: AWS::Serverless::Function
Properties:
Handler: ConvertPicture.handler
FunctionName: !Sub "${paramEnvironment}${paramFeatureBranch}_${paramServiceName}_ConvertPicture"
CodeUri: dist/ConvertPicture.js
Policies:
- SNSPublishMessagePolicy:
TopicName: !If
- condIsLocalOrBranch
- !Sub "${paramEnvironment}${paramFeatureBranch}_${paramServiceName}-ts_catalog_pictureUploaded"
- !Sub "${paramEnvironment}${paramFeatureBranch}_catalog_PictureUploaded"
- DynamoDBCrudPolicy:
TableName: !Ref resDynamoMenuJobsTable
测试脚手架作为分支部署的一部分与微服务同时部署。下图显示了脚手架代码必须遵守与其他服务相同的部署标准。部署完成后,我们将运行功能测试。
带有测试脚手架的分支部署CI/CD管道图。
只有当使用脚手架的隔离服务测试通过后,才有可能并入主线分支。测试脚手架不会部署到我们的集成环境中。一旦主线中出现变更,我们就会使用计划作业来清理任何可能尚未手动拆除的隔离环境(服务和脚手架)。这一点很重要,因为给定账户的可用资源数量是有上限的。
测试,更上一层楼
测试脚手架允许我们的团队在合并到主线分支并部署到集成环境之前,使用与服务开发完全相同的技术(CFT 和本地 AWS 资源)独立开发和测试微服务功能。通过脚手架控制输入和输出,我们可以围绕关键流程建立更深入的测试集,并有效地确保高质量。
由于测试脚手架允许我们创建的不仅仅是基本的创建、读取更新和删除测试,我们还能构建与业务用例相关联的深度功能流。这些深入的功能测试让我们对微服务在合并到主线并部署到环境中之前的变化充满信心。此外,将大部分自动化保持在这一级别(以及单元测试)可最大限度地减少行程测试(journey test)(即:"端到端测试")的使用,并支持测试金字塔所有级别的健康自动化套件。
一些亮点:
每次推送到版本库时都会执行测试。
测试速度快!针对我们的一项主要服务进行的几百个测试中,运行时间最长的一组测试不到10分钟。
测试对即将进行的合并进行把关。如果测试不通过,你的提交就无法合并,也不会影响其他团队在其他服务上的工作。
测试定义并控制自己的所有数据,使其具有完全的确定性和密封性。
经验教训
在我们学习实施和利用测试脚手架的最佳方法时,需要不断地尝试和犯错。我们既要确定如何将其纳入项目,又要确定将其作为管道的一部分进行构建和部署的最佳方式。以下是一些关键经验:
KISS-("Keep It Simple, Stupid")我们倾向于将测试脚手架中的行为复杂化,从而不需要重复依赖逻辑来生成适当的响应。
清理- 当变更提交到主线时,服务和测试脚手架的测试版本就会被遗留下来。因此,我们建立了清理工作,以便从AWS开发账户中删除不再处于活动开发阶段或已数天未更新的工件。事实上,一切都由云形成堆栈定义,这使得根据需要重新部署变更变得非常容易。
契约演进(Contract evolution) - 测试脚手架中使用的请求和响应与实际服务契约保持一致至关重要。否则,就无法测试应该测试的内容。团队间的沟通和合同测试仍然至关重要。
与任何新流程一样,测试脚手架的开发和部署也需要时间和精力,但它最终成为了我们整体微服务开发方法中的一个强大工具和重要资产。