QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

  • 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:292879
用户头像

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

关注

评论 1 条评论

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

霸榜巨作!阿里内部顶级大佬整理(Redis 5设计与源码分析)

钟奕礼

Java 程序员 java面试 java编程

API渗透测试4个关键步骤

阿泽🧸

11月月更 API渗透测试

仅hashmap一道面试题我就搞定了面试官成功入职面试官:我裂开了

钟奕礼

Java java面试 java编程 程序员、

阿里、百度、美团、面试题大集合,愿你更轻松拿下大厂offer

钟奕礼

Java java面试 java编程 程序员、

三到五年互联网公司Java面试题大全

钟奕礼

Java 程序员 java面试 java编程

算法题学习---链表的奇偶重排

桑榆

算法题 11月月更

网络核心笔记(二)

lxmoe

学习笔记 网络 11月月更

Java 后端 100多道面试题,多看点题,没坏处!

钟奕礼

Java 程序员 java面试 java编程

GitHub标星75k,阿里15W字的Spring高级文档(全彩版),真的太香了

程序知音

Java spring ssm java架构 后端技术

Camtasia2023免费电脑录屏视频软件使用教程

茶色酒

Camtasia Camtasia2023

2022全网最全最新Java面试题-独家内部教材

钟奕礼

Java 程序员 java面试 java编程

Java中的String类常用方法

共饮一杯无

Java string 11月月更

从基础到实战,阿里巴巴高并发系统设计全彩版手册限时开源

Java全栈架构师

程序员 面试 程序员人生 高并发 架构师

如何召回流失用户

穿过生命散发芬芳

11月月更 流失召回

集合工具类Collections指南,以及Comparable和Comparator排序详解

共饮一杯无

Java 集合 11月月更

Redis分布式锁剖析和几种客户端的实现

C++后台开发

redis 分布式 后端开发 C++开发

ABBYY FineReader16最新版PDF编辑器功能介绍

茶色酒

abbyy

2022成功入职阿里:阿里的三套Java研发岗面试题总结(文末有答案)

钟奕礼

Java java面试 java编程 程序员、

亿级万物互联新时代的物联网消息中间件EMQX调研

宋小生

物联网 mqtt emqx

LeetCode题解:783. 二叉搜索树节点最小距离,栈,JavaScript,详细注释

Lee Chen

JavaScript 算法 LeetCode

Python进阶(五十三)Flask Web开发实现将表单渲染成HTML

No Silver Bullet

Python flask web开发 11月月更

CleanMyMac2023Mac系统电脑磁盘优化软件

茶色酒

CleanMyMac CleanMyMac2023

赞不绝口!仅靠阿里P9分享的 Redis 工作手册,拿到60W年薪Offer

程序知音

Java 数据库 redis 后端技术 Redis 6.0

CorelDraw2023主要功能特性

茶色酒

CorelDraw2023 CorelDraw

FL Studio21最新版编曲DJ舞曲制作软件

茶色酒

FL Studio FL Studio 21

网络核心笔记(一)

lxmoe

学习笔记 网络 11月月更

一文搞懂MySQL表字段类型长度的含义

闫同学

MySQL 数据库 11月月更

8年Java开发含泪刷题,架构岗现在好难进,有点崩溃

钟奕礼

Java 程序员 java面试 java编程

2022年华为Java面经,还没搞懂JVM

钟奕礼

Java 程序员 Java 面试 java编程

这20道微服务面试题,阿里、字节、美团、百度面试都问了

钟奕礼

Java 程序员 java面试 java编程

【Logback+Spring-Aop】实现全面生态化的全链路日志追踪系统服务插件「Logback-MDC篇」

洛神灬殇

log4j logback 全链路追踪 11月日更 MDC

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