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

分享于2021年05月16日 系统
多年以后,当你查看过去一直在开发的代码时,或许会沾沾自喜。因为这些基础代码库是你运用各种已知的设计模式和设计原则构建的。但你并非代码库的唯一开发者。当你决定后退一步远观整体情况时,你看到的可能是下图的样子:事实证明,情况会在做了内部审计后变得更糟。我们做了大量的集成测试和端到端测试,却几乎没有做单元测试。多年来,我们一直在使部署过程更为复杂化。现在,代码库看起来更像是下图:虽然我们可以限制端到端测试的数量,但正是这些测试捕获了大量存在于集成测试之外的错误。我们面对的问题是无法捕获集成(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 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 /foorequest: method: GET url: /fooresponse: 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)行为,并返回虚数据。例如,控制器可如下定义:@RestControllerclass 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 /barrequest: method: GET url: /barresponse: 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 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 只有在合同测试通过后才会发布)。- END -往期回顾◆监控全覆盖,接入只需 5 分钟:爱奇艺内容中台基于 CAT 的服务监控实践◆您遵循过这些Jenkins优秀实践吗?◆从技术谈到管理,把系统优化的技术用到企业管理