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

\

本文要点

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

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

\\

c4f19a222f5ae28f03cb2c268334977a.jpg
图片来源

\\

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

\\

cc910a0fa114e67660911a5eaebb201a.png
图片来源

\\

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

\\

b886d3bbcf96e9ee1645cd02d2aec75c.jpg
图片来源

\\

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

\\

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

\\

假定我们的架构如下:

\\

2c8ee62cd8d9071414d6316211089f21.png

\\

我们聚焦于其中的两个主要服务: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\u0026lt;String\u0026gt; entity = new RestTemplate()\           .getForEntity(“http://localhost:6543/foo“, String.class);\\     BDDAssertions.then(entity.getStatusCode().value()).isEqualTo(200);\     BDDAssertions.then(entity.getBody()).isEqualTo(“OK”);\  }\}
\\

74b0504004dd3696a69ffb98182bc39e.png

\\

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

\\

1f45a1cb07c55d2fbc151b78162011c3.png

\\

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

\\

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

\\

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

\\

将失败在流水线中前移

\\

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

\\

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

\\

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

\\

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

\\

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

\\

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

\\

目前有多种实现契约测试的工具,我们认为其中广为采用的只有两种,即Spring Cloud ContractPact。在本文中,我们将聚焦于前者,详细介绍如何使用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\\t
  • 那么返回一个状态为200、内容为“OK”的响应。\

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

\\

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

\\

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

\\

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

\\

5893cce70fd4e97afee015914f825a9f.png

\\

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

\\

c5e8e03149d35a2c7acd4158642b8ac7.png

\\

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

\\
\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\
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值