作为一位供职于大型企业的开发人员,当你查看过去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”
上面的契约中定义了:
根据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。
我们可以使用契约测试实现多个目标,包括:
希望读者与我保持联系!可通过 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