【QCon】精华内容上线92%,全面覆盖“人工智能+”的典型案例!>>> 了解详情
写点什么

使用契约测试提高分布式系统的质量

  • 2018-08-13
  • 本文字数:12018 字

    阅读完需:约 39 分钟

本文要点

  • 分布式组件间的交互情况难以测试。一个原因是消费者端创建的测试 Stub ,并在生产者的代码中得到测试。
  • 单元测试本身不能回答各组件间是否适合一起工作。开展集成测试是有必要的,尤其是测试客户与服务器之间的通信。
  • 契约测试定义了组件间的会话情况。
  • Spring Cloud Contract 可从生产者的代码中生成测试 Stub,并共享给消费者。进而,消费者可使用 Stub Runner 自动消费这些 Stub。
  • 在消费者驱动合约的方式下,合约由消费者建立,进而被生产者使用。

作为一位供职于大型企业的开发人员,当你查看过去 10 年中一直在开发的代码时,一定会产生沾沾自喜感。因为这些基础代码库是你运用各种已知的设计模式和设计原则构建的。但你并非代码库的唯一开发者。当你决定后退一步远观整体情况时,你看到的可能是下图的样子:



图片来源

事实证明,情况会在做了内部审计后变得更糟。我们做了大量的集成测试和端到端测试,却几乎没有做单元测试。



图片来源

多年来,我们一直在使部署过程更为复杂化。现在,代码库看起来更像是下图:



图片来源

虽然我们可以限制端到端测试的数量,但正是这些测试捕获了大量存在于集成测试之外的错误。我们面对的问题是无法捕获集成(HTTP 或消息传递)出错时的异常情况。

为什么不尝试采用“快速失败”机制?

假定我们的架构如下:

我们聚焦于其中的两个主要服务:Legacy Service 和 Customer Rental History Service。

在 Legacy Service 的集成测试中,我们试图运行一个测试,将请求发送给 Customer Rental History Service 服务的 Stub。作为遗留应用,我们手工编写该 Stub。也就是说,我们使用 WireMock 等工具模拟对特定请求的响应。下面给出该场景的部分代码示例:

复制代码
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
// 在特定端口启动 WireMock。
@AutoConfigureWireMock(port = 6543)
public class CustomerRentalHistoryClientTests {
@Test
public void should_respond_ok_when_foo_endpoint_exists() {
// 构建 Legacy Service 的 Stub,使 WireMock 按设计做出特定的行为。
WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(“/foo”))
.willReturn(WireMock.aResponse().withBody(“OK”).withStatus(200)));
ResponseEntity<String> entity = new RestTemplate()
.getForEntity(“http://localhost:6543/foo“, String.class);
BDDAssertions.then(entity.getStatusCode().value()).isEqualTo(200);
BDDAssertions.then(entity.getBody()).isEqualTo(“OK”);
}
}

那么这样的测试会存在什么问题?实际情况下,端点可能并不存在。该问题通常在生产环境中才会出现。

这究竟意味着什么?为什么测试通过而生产代码却会产生失败?!该问题的发生,是因为在消费者端创建的 Stub 未对生产者的代码做过测试。

这意味着存在不少漏报情况。实际上也意味着我们浪费时间(也就是金钱)运行没有任何收益的集成测试(并且应该被删除)。更糟糕的是,我们并未通过端到端测试,还需要花费大量时间调试失败的原因。

是否有办法加速快速失败(Fail-Fast)?该方法是否可能在开发人员的机器上实现?

将失败在流水线中前移

在我们的部署流水线中,我们希望尽可能地前移失败的构建。这意味着,我们不希望直至流水线结束才能看到存在于算法中的错误,或是才能看到存在于集成中的错误。我们的目标是,一旦存在问题,就让构建产生失败(Fail-Fast)。

为实现快速失败,并立刻从应用中获得反馈,我们从单元测试开始,采用一种测试驱动的开发方式。这是着手绘制我们想要实现架构的一种最佳方式。我们可以对每项功能做独立测试,并立刻从这些部分片段中得到响应。通过单元测试,更易于并会更快地发现特定错误或故障的原因。

单元测试是否足以解决问题?事实并非如此,因为任何事情都不是孤立的。我们还需要将通过单元测试的各个组件集成在一起,验证它们是否适合一起正常工作。一个很好的例子是断言(assert)是否正确启动了一个 Spring 上下文,并注册了所需的全部 Bean。

现在回到我们的主要问题上,即客户端和服务器间通信的集成测试。我们是否必须要手工编写 HTTP/ 消息传递 Stub,并适应生产者间的任何更改?或是另有更好的方法解决这个问题?下面我们将介绍契约测试(Contract Test),它可帮助我们解决这个问题。

什么是契约测试?它是如何工作的?

两个应用在相互通信前,会正式确定两者间的消息发送和接收方式。我们并非要探讨通信的模式,因为我们并不关注所有可能的请求和响应字段,以及 HTTP 通信的接收方法。我们想要定义的是可实际发生的会话,称之为“契约”(Contract)。契约是 API/ 消息生产者与消费者之间的共识,它定义了会话的具体形式。

目前有多种实现契约测试的工具,我们认为其中广为采用的只有两种,即 Spring Cloud Contract Pact 。在本文中,我们将聚焦于前者,详细介绍如何使用 Spring Cloud Contract 实现契约测试。

Spring Cloud Contract 支持以 Groovy、YAML 或 Pact 文件方式定义契约。下面给出的例子使用 YAML 定义契约:

复制代码
description: |
Represents a scenario of sending request to /foo
request:
method: GET
url: /foo
response:
status: 200
body: “OK”

上面的契约中定义了:

  • 如果发送一个具有 GET 方法的 HTTP 请求到 URL 地址“/foo”,I
  • 那么返回一个状态为 200、内容为“OK”的响应。

根据 WireMock Stub,我们需要编码实现消费者的测试需求。

只存储这样的会话片段并没有多少意义。如果不能实际验证通信双方是否保持了承诺,那么这样的契约定义与记在纸上的或 Wiki 页面上的毫无二致。Spring 中非常重视承诺。如果一方编写了契约,那么我们需要从中生成测试,验证生产者是否达到了契约的要求。

要实现这样的测试,我们必须在生产者端(即 Customer History Service 应用)设置 Spring Cloud Contract 的 Maven 或 Gradle 插件,定义契约,并将契约置于适当的文件夹结构中。之后,插件将会读取契约的定义,根据契约生成测试和 WireMock Stub。

必须谨记,不同于先前在消费者端(即 Legacy Service)生成 Stub 的做法,现在 Stub 和 测试都是从生产者端(即 Customer History Service)生成的。

下图显示了从 Customer History Service 看到的流程。

那么生成的测试的具体内容是怎样的?下面给出生成的测试代码:

复制代码
public class RestTest extends RestBase {
@Test
public void validate_shouldReturnOKForFoo() throws Exception {
// 给定:
MockMvcRequestSpecification request = given();
// 一旦:
ResponseOptions response = given().spec(request)
.get(“/foo”);
// 那么:
assertThat(response.statusCode()).isEqualTo(200);
// 以及:
String responseBody = response.getBody().asString();
assertThat(responseBody).isEqualTo(“OK”);
}

Spring Cloud Contract 使用一种称为“ Rest Assured ”的框架,发送和接收测试 REST 请求。Rest Assured 中包含了一些遵循良好 BDD(Behavior Driven Development)实践的 API。测试是描述性的,它可很好地引用契约中定义的所有请求和响应条目。那么,为什么在代码中还需要指定基类(Base Class)?

契约测试在本质上并非是对功能做断言。我们想要实现的是对语法做验证,即生产者和消费者是否可在生产环境中成功通信。

在基类中可建立对应用服务的模仿(Mock)行为,并返回虚数据。例如,控制器可如下定义:

复制代码
@RestController
class CustomerRentalHistoryController {
private final SomeService someService;
CustomerRentalHistoryController(SomeService someService) {
this.someService = someService;
}
@GetMapping(“/foo”)
String response() {
return this.someService.callTheDatabase();
}
}
interface SomeService {
String callTheDatabase();
}

如果我们希望能快速地完成这些测试,并验证双方是否可正常通信,因此我们并不想在契约测试中调用数据库。这样,我们需要在基类中模仿应用服务的情况。具体代码如下:

复制代码
public class BaseClass {
@Before
public void setup() {
RestAssuredMockMvc.standaloneSetup(
new CustomerRentalHistoryController(new SomeService() {
@Override public String callTheDatabase() {
return “OK”;
}
}));
}
}

在设置插件并运行生成的测试后,我们注意到在“generated-test-resources”文件夹中生成了一些 Stub,它们表现为具有“-stubs”后缀的额外工件(artifact)。这些工件中包含了契约和 Stub,其中 Stub 是 WireMock Stub 的标准 JSON 表示,内容如下:

复制代码
{
  "id" : "63389490-864e-483c-9059-c1eba8b46b37",
  "request" : {
    "url" : "/foo",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "OK",
    "transformers" : [ "response-template" ]
  },
  "uuid" : "63389490-864e-483c-9059-c1eba8b46b37"
}

该文件表示了一对响应已被验证为真实的请求(由于通过了所生成的测试)。当运行./mvnw做部署,或是运行./gradlew做发布时,应用的完备打包(Fat Jar)以及所有的 Stub 将会上传到 Nexus/Artifactory。这样,我们开箱即可用地获得了可重用的 Stub。这些 Stub 在通过生产者的验证后,只需生成、断言和上传一次。

下面介绍为了实现 Stub 的重用,我们应如何修改消费者端的测试。

Spring Cloud Contract 提供了一个称为“Stub Runner”的组件。正如其名称所示,Stub Runner 用于发现并运行 Stub。它可从 Artifactory/Nexus、classpath、Git 代码库或 Pact broker 等多个位置获取 Stub。由于 Spring Cloud Contract 具有可插拔特性,你也可以上传自己的实现。无论选取了何种 Stub 存储,都可以更改 Stub 在项目间的共享方式。下图展示了 Stub 在通过契约测试后,上传到 Stub 存储以供其它项目重用。

Spring Cloud Contract 并不需要用户实际去使用 Spring。作为消费者,我们可以调用 StubRunner JUnit Rule 下载并启动 Stub。代码如下:

复制代码
public class CustomerRentalApplicationTests {
   @Rule public StubRunnerRule rule = new StubRunnerRule()
         .downloadStub("com.example:customer-rental-history-service")
         .withPort(6543)
         .stubsMode(StubRunnerProperties.StubsMode.REMOTE)
         .repoRoot("https://my.nexus.com/");
   @Test
   public void should_return_OK_from_a_stub() {
      String object = new RestTemplate()
            .getForObject("http://localhost:6543/foo", String.class);
      BDDAssertions.then(object).isEqualTo("OK");
   }
}

上面的代码实现从https://my.nexus.com下提供的 Nexus 安装获取具有组 ID“com.example”和工件 ID“customer-rental-history-service”的应用 Stub。之后,下载的 Stub 用于在端口6543启动 HTTP 服务器 Stub。现在,测试可以直接引用 Stub 服务器。工作流如下图所示:

那么该方法产生什么输出?

  • 从消费者角度看,如果不能与生产者通信,会产生快速失败。
  • 从生产者角度看,可看到代码的修改是否会破坏与客户达成的契约。

该方法称为“生产者契约法”。其中,契约由生产者定义,所有消费者需要遵循定义在契约中的指南。

还有另一种契约操作方法,称为“消费者驱动契约法”。设想消费者单独为特定的生产者创建了一套契约。下面给出定义在生产者代码库端的文件夹结构:

复制代码
└── contracts
├── bar-consumer
│   ├── messaging
│   │   ├── shouldSendAcceptedVerification.yml
│   │   └── shouldSendRejectedVerification.yml
│   └── rest
│   └── shouldReturnOkForBar.yml
└── foo-consumer
├── messaging
│   ├── shouldSendAcceptedVerification.yml
│   └── shouldSendRejectedVerification.yml
└── rest
└── shouldReturnOkForFoo.yml

假定该文件夹结构代表 Customer Rental History 服务需要达成的契约。从中我们可看到,Customer Rental History 服务具有两个消费者:bar-consumer 和 foo-consumer。这样,我们了解了消费者是如何使用 API 的。此外,如果我们做出了一些重大的修改(例如,修改或移除了响应中的某个域),那么我们将会准确地知道受此影响的消费者。

如果 foo-consumer 需要端点“/foo”返回“OK”内容,而 bar-consumer 需要端点“/bar”返回“OK”。这时,shouldReturnOkForBar.yml 的内容如下:

复制代码
description: |
Represents a scenario of sending request to /bar
request:
method: GET
url: /bar
response:
status: 200
body: "OK"

如果我们对 Customer Rental History 服务做了一些重构,移除了"/bar"映射。所生成的测试可准确地指出受到破坏的消费者。下面给出运行命令./mvnw clean install的输出情况:

复制代码
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] RestTest.validate_shouldReturnOkForBar:67 expected:<[200]> but was:<[404]>
[INFO]
[ERROR] Tests run: 11, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

在消费者端,需要设置 Stub Runner 按每个消费者特性使用 Stub。这意味着,将只加载对应于特定消费者的 Stub。下面给出测试的例子:

复制代码
@RunWith(SpringRunner.class)
// 假定客户名为 foo-consumer。
@SpringBootTest(webEnvironment = WebEnvironment.MOCK,
properties = {"spring.application.name=foo-consumer"})
// 从本地.m2 文件加载 Stub “com.example:customer-rental-history-service”,并在随机端口上运行。
// 此外,设置 stubsPerConsumer 的特性。
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = "com.example:customer-rental-history-service",
stubsPerConsumer = true)
public class FooControllerTest {
// 获取 Stub“customer-rental-history-service”的运行端口。
@StubRunnerPort("customer-rental-history-service") int producerPort;
@Test
public void should_return_foo_for_foo_consumer() {
String response = new TestRestTemplate()
.getForObject("http://localhost:" + this.producerPort + "/foo",
String.class);
BDDAssertions.then(response).isEqualTo("OK");
}
@Test
public void should_fail_to_return_bar_for_foo_consumer() {
ResponseEntity<String> entity = new TestRestTemplate()
.getForEntity("http://localhost:" + this.producerPort + "/bar",
String.class);
BDDAssertions.then(entity.getStatusCodeValue()).isEqualTo(404);
}
}

契约是否必须存储在生产者端?并无此必要。契约可以存储在单独的代码库中。无论如何选择,输出都是编写分析这些契约的测试,并自动生成如何使用 API 的文档!

此外,鉴于我们知道各服务间的父子关系,我们可以轻易地绘制出服务的依赖关系图。

考虑如下文件夹结构:

可以绘制如下的依赖关系图:

契约测试还有哪些功能?

在测试金字塔中,契约测试应该与单元测试和集成测试一起占有一席之地。

我们可以导出 Spring Cloud Pipelines。建议将契约测试置于部署流水线(API 兼容性检查)的关键步骤。我们还建议部署流水线中以单独过程运行 Stub Runner,以围绕应用构建 Stub。

总结

我们可以使用契约测试实现多个目标,包括:

  • 建立良好的 API(如果消费者正在推动修改 API,那么通过契约测试可确切地知道 API 应该如何满足消费者的需求)。
  • 一旦集成出现故障,可实现快速失败(如果测试无法发送 Stub 可理解的请求,那么生产环境应用也一定不会理解)。
  • 一旦 API 发生重大修改,可实现快速失败(契约测试可准确地指出哪处 API 修改具有破坏性)。
  • Stub 的可重用性和有效性(Stub 只有在合同测试通过后才会发布)。

希望读者与我保持联系!可通过 Gitter 、阅读文档 Spring Cloud Contract 项目给出反馈。

作者简介

Marcin Grzejszczak是《Mockito Instant》和《Mockito Cookbook》这两本书的作者,也是《Applied Continuous Delivery Live Lessons》一书的合著者。此外,Marcin 也是华沙 Groovy 用户组和 Warsaw Cloud Native Meetup 的联合创始人,在 Pivotal 负责 Spring Cloud Sleuth、Spring Cloud Contract 和 Spring Cloud Pipelines 项目。可以通过 Twitter( https://twitter.com/mgrzejszczak )联系他。.

查看英文原文: How Contract Tests Improve the Quality of Your Distributed Systems

2018-08-13 18:292571
用户头像

发布了 391 篇内容, 共 125.9 次阅读, 收获喜欢 255 次。

关注

评论 1 条评论

发布
暂无评论
发现更多内容

第5周作业_贷款申请流程图

园子

互联网金融

遇见ZooKeeper:初识

Jackey

zookeeper

Mybatis【19】-- Mybatis自关联多对多查询

秦怀杂货店

面试系列一:精选大数据面试真题10道(混合型)-附答案详细解析

五分钟学大数据

大数据 面试 28天写作

产品经理是吃青春饭的吗?

涛哥 数字产品和业务架构

产品经理

【管理笔记11】优秀人才的十二个特质

L3C老司机

28天写作

(28DW-S8-Day3) 比特币、 区块链是什么?

mtfelix

比特币 区块链 28天写作

梦境交互:做个现代灵媒,考虑一下?

脑极体

New转乾坤——云网融合真正的打开方式!

脑极体

MYSQL 索引篇(上)

new life

MySQL性能优化 执行计划 MySQL使用 索引性能

压力太大的话,就放点气儿吧

道伟

28天写作

week13作业

zbest

Elasticsearch 组合查询

escray

elastic 七日更 28天写作 死磕Elasticsearch 60天通过Elastic认证考试 2月春节不断更

Selenium 八大定位,滚雪球学 Python 番外系列

梦想橡皮擦

Python 28天写作 2月春节不断更

python爬虫-学习urllib和requests使用,模拟请求

大佬sam

二月春节不断更

Eclipse快捷键大全

lnngle

Java eclipse 快捷键

LeetCode 采坑两次后,我终于学会了 BFS

与你一起学算法

Python BFS 数据结构与算法

MYSQL 索引篇(下)

new life

MySQL MySQL性能优化 多字段联合验证 索引性能

如何监控Nginx的upstream后端server

运维研习社

nginx 负载均衡 zabbi

28天瞎写的第二百四十一天:正念是不是迷信、玄学、神棍?

树上

冥想 28天写作 正念 迷信

聊聊如何做好计划

数列科技杨德华

28天写作

什么容易被记住——造梦师指南

Justin

心理学 28天写作 游戏设计

ConcurrentBag 听过没?好家伙高并发知识点十分密集!一种并发优化思路!

yes

Java 面试 并发

Impala 3.4在网易的最新实践

DataFunTalk

Mybatis【20】-- Mybatis延迟加载怎么处理?

秦怀杂货店

数据库 缓存 mybatis 加载

产品训练营第四章作业(二)

Arnold

lua 对象编程解读

程序员与厨子

lua 学习 编程

三、创建、更新和删除文档

Kylin

读书笔记 七日更 分布式数据库mongodb 二月春节不断更

我的配置中心知识整理

老白鹿

微服务 技术选型 配置中心 配置管理

真正的勇士,敢于重新开始,敢于再次开始😂

Nydia

程序员心中的一道坎:主存的编址与计算和串并联系统!

冰河

程序员 操作系统 计算 编址 串并联系统

使用契约测试提高分布式系统的质量_DevOps & 平台工程_Marcin Grzejszczak_InfoQ精选文章