您将为一切做出回答! 通过开发商的眼光看消费者驱动的合同

在本文中,我们将讨论消费者驱动的合同解决的问题,并展示如何使用带有Node.js和Spring Boot的Pact示例来应用它。 并讨论这种方法的局限性。


发行


在测试产品时,通常使用场景测试,其中检查了系统在特定选择的环境中各个组件的集成。 这种对实时服务的测试给出了最可靠的结果(不包括战斗中的测试)。 但同时,它们是最昂贵的之一。

  • 人们常常错误地认为集成环境不应容错。 SLA,很少说出对此类环境的保证,但是如果没有保证,团队必须要么推迟发布,要么希望取得最好的成绩,并在没有测试的情况下投入战斗。 尽管所有人都知道希望不是战略 。 而新型的基础架构技术只会使集成环境的工作复杂化。
  • 另一个麻烦是处理测试数据 。 许多场景需要系统的某种状态,即灯具。 它们应与数据打多近? 如何在测试前更新它们并在完成后进行清洁?
  • 测试太不稳定了 。 不仅因为我们在第一段中提到的基础架构。 该测试可能会失败,因为附近的团队启动了自己的检查,从而破坏了系统的预期状态! 许多错误的否定检查和@Ignored测试在@Ignored结束了他们的生命。 同样,集成的不同部分可能由不同的团队支持。 他们推出了一个新的有错误的候选版本-他们破坏了所有消费者。 有人用专用的测试循环解决了这个问题。 但是以增加支持成本为代价。
  • 这样的测试需要很多时间 。 即使考虑到自动化,也可以期待数小时的结果。
  • 最重要的是,如果测试确实正确,那么始终无法立即找到问题的原因。 它可以隐藏在集成层的深处。 也可能是许多系统组件的状态意外组合的结果。

在集成环境中进行稳定的测试需要QA,dev甚至ops投入大量资金。 难怪它们处于测试金字塔的最顶端。 这样的测试很有用,但是资源经济不允许他们检查所有内容。 其价值的主要来源是环境。

在同一金字塔下面是其他测试,在这些测试中,我们使用隔离检查来换取较小的支持难题。 粒度越大,测试规模越小,对外部环境的依赖性就越小。 金字塔的最底部是单元测试。 我们检查各个功能,类,我们所进行的操作不像商业语义那样,而是针对特定实现的构造。 这些测试可提供快速反馈。

但是,一旦我们走下金字塔,就必须用某种东西来代替环境。 存根出现-作为整个服务以及编程语言的单个实体。 借助插头,我们可以隔离测试组件。 但是它们也会降低检查的有效性。 如何确保存根返回正确的数据? 如何保证其质量?

该解决方案可以是全面的文档 ,描述了各种情况以及系统组件的可能状态。 但是任何表述仍然保留解释的自由。 因此,良好的文档记录是一个生动的工件,随着团队对问题领域的了解,它会不断提高。 然后如何确保符合文档存根?

在许多项目中,您可以观察到存根由开发测试工件的同一个人编写的情况。 例如,移动应用程序的开发人员自己为测试创建存根。 结果,程序员可以以自己的方式理解文档(这是完全正常的),使存根具有错误的预期行为,根据存根编写代码(带有绿色测试),并且在真正的集成过程中会发生错误。

此外,文档通常向下游移动-客户端使用服务规范(在这种情况下,另一个服务可以是该服务的客户端)。 它没有表达消费者如何使用数据,根本需要什么数据,他们对该数据做出什么假设。 这种无知的结果是希律定律



Hyrum Wright长期以来一直在Google内部开发公共工具,并观察到最小的变化如何导致使用其库的隐式(未记录)功能的客户崩溃。 这种隐藏的连接使API的发展变得复杂。

使用消费者驱动的合同可以在某种程度上解决这些问题。 像任何方法和工具一样,它具有一定的适用性和成本,我们还将考虑这些范围。 这种方法的实施已经达到足够的成熟度,可以尝试他们的项目。

什么是CDC?


三个关键要素:

  • 合同 。 使用某些DSL描述,取决于实现。 它以交互场景的形式包含对API的描述:如果特定请求到达,则客户端应收到特定响应。
  • 客户测试 。 此外,他们使用存根,该存根是从合同自动生成的。
  • 测试API 。 它们也是从合同生成的。

因此,合同是可执行的。 该方法的主要特点是,对API行为的要求从客户端到服务器都向上游移动

合同侧重于对消费者真正重要的行为。 使有关API的假设明确。

CDC的主要目标是使开发人员和客户开发人员对API行为有所了解。 这种方法与BDD很好地结合在一起,在三个好友的会议上,您可以草拟合同的空白。 最终,该合同还有助于改善沟通; 在问题区域内达成共识,并在团队内部和团队之间实施解决方案。

契约


考虑使用CDC作为其实施之一的Pact的示例。 假设我们为会议参与者创建了一个Web应用程序。 在下一次迭代中,团队将制定演示时间表-到目前为止,还没有投票或注释之类的故事,仅是报告网格的输出。 示例的源代码在这里

四分之三的会议上,产品,测试人员,后端开发人员和移动应用程序举行了会议。 他们说

  • 带有文本的列表将显示在UI中:报告标题+发言人+日期和时间。
  • 为此,后端必须返回数据,如下例所示。

 { "talks":[ { "title":"      ", "speakers":[ { "name":" " } ], "time":"2019-05-27T12:00:00+03:00" } ] } 

之后,前端开发人员开始编写客户端代码(前端的后端)。 他在项目中安装了契约合同库:

 yarn add --dev @pact-foundation/pact 

并开始编写测试。 它配置本地存根服务器,该服务器将使用报告计划模拟服务:

 const provider = new Pact({ //      consumer: "schedule-consumer", provider: "schedule-producer", // ,     port: pactServerPort, //  pact     log: path.resolve(process.cwd(), "logs", "pact.log"), // ,     dir: path.resolve(process.cwd(), "pacts"), logLevel: "WARN", //  DSL  spec: 2 }); 

合同是一个JSON文件,描述了客户端与服务交互的场景。 但是您不需要手动描述它,因为它是由代码中存根的设置形成的。 测试前的开发人员描述以下行为。

 provider.setup().then(() => provider .addInteraction({ uponReceiving: "a request for schedule", withRequest: { method: "GET", path: "/schedule" }, willRespondWith: { status: 200, headers: { "Content-Type": "application/json;charset=UTF-8" }, body: { talks: [ { title: "      ", speakers: [ { name: " " } ], time: "2019-05-27T12:00:00+03:00" } ] } } }) .then(() => done()) ); 

在此示例中,我们指定了特定的预期服务请求,但是pact-js还支持几种用于确定match的方法

最后,程序员对使用此存根的那部分代码进行测试。 在下面的示例中,为简单起见,我们将直接调用它。

 it("fetches schedule", done => { fetch(`http://localhost:${pactServerPort}/schedule`) .then(response => response.json()) .then(json => expect(json).toStrictEqual({ talks: [ { title: "      ", speakers: [ { name: " " } ], time: "2019-05-27T12:00:00+03:00" } ] })) .then(() => done()); }); 

在实际项目中,这可以是单独的响应解释功能的快速单元测试,也可以是用于显示从服务接收的数据的慢速UI测试。

在测试运行期间,pact验证存根是否接收到测试中指定的请求。 差异可以在pact.log文件中视为diff。

 E, [2019-05-21T01:01:55.810194 #78394] ERROR -- : Diff with interaction: "a request for schedule" Diff -------------------------------------- Key: - is expected + is actual Matching keys and values are not shown { "headers": { - "Accept": "application/json" + "Accept": "*/*" } } Description of differences -------------------------------------- * Expected "application/json" but got "*/*" at $.headers.Accept 


如果测试成功,将以JSON格式生成合同。 它描述了API的预期行为。

 { "consumer": { "name": "schedule-consumer" }, "provider": { "name": "schedule-producer" }, "interactions": [ { "description": "a request for schedule", "request": { "method": "GET", "path": "/schedule", "headers": { "Accept": "application/json" } }, "response": { "status": 200, "headers": { "Content-Type": "application/json;charset=UTF-8" }, "body": { "talks":[ { "title":"      ", "speakers":[ { "name":" " } ], "time":"2019-05-27T12:00:00+03:00" } ] }}} ], "metadata": { "pactSpecification": { "version": "2.0.0" } } } 

他将此合同交给后端开发人员。 假设该API在Spring Boot上。 Pact有一个可以与MockMVC一起使用的pact-jvm-provider-spring库。 但是,我们将看一下Spring Cloud Contract,它在Spring生态系统中实现了CDC。 它使用自己的合同格式,但还有一个扩展点,用于连接其他格式的转换器。 它的本机合同格式仅受Spring Cloud Contract本身支持-与Pact不同,Pact具有针对JVM,Ruby,JS,Go,Python等的库。

假设在我们的示例中,后端开发人员使用Gradle构建服务。 它连接以下依赖项:

 buildscript { // ... dependencies { classpath "org.springframework.cloud:spring-cloud-contract-pact:2.1.1.RELEASE" } } plugins { id "org.springframework.cloud.contract" version "2.1.1.RELEASE" // ... } // ... dependencies { // ... testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier' } 

并将从frotender收到的Pact合同放入src/test/resources/contracts目录。

默认情况下,spring-cloud-contract插件会从中减去合同。 在组装期间,执行generateContractTests gradle任务,该任务在build / generate-test-sources目录中生成以下测试。

 public class ContractVerifierTest extends ContractsBaseTest { @Test public void validate_aggregator_client_aggregator_service() throws Exception { // given: MockMvcRequestSpecification request = given() .header("Accept", "application/json"); // when: ResponseOptions response = given().spec(request) .get("/scheduler"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).array("['talks']").array("['speakers']").contains("['name']").isEqualTo( /*...*/ ); assertThatJson(parsedJson).array("['talks']").contains("['time']").isEqualTo( /*...*/ ); assertThatJson(parsedJson).array("['talks']").contains("['title']").isEqualTo( /*...*/ ); } } 


开始此测试时,我们将看到一个错误:

 java.lang.IllegalStateException: You haven't configured a MockMVC instance. You can do this statically 

由于我们可以使用不同的工具进行测试,因此我们需要告诉插件我们已经配置了哪个工具。 这是通过基类完成的,该基类将继承合同生成的测试。

 public abstract class ContractsBaseTest { private ScheduleController scheduleController = new ScheduleController(); @Before public void setup() { RestAssuredMockMvc.standaloneSetup(scheduleController); } } 


要在生成过程中使用此基类,您需要配置spring-cloud-contract gradle插件。

 contracts { baseClassForTests = 'ru.example.schedule.ContractsBaseTest' } 


现在,我们生成了以下测试:
 public class ContractVerifierTest extends ContractsBaseTest { @Test public void validate_aggregator_client_aggregator_service() throws Exception { // ... } } 

测试成功启动,但失败,并显示验证错误-开发人员尚未编写该服务的实现。 但是现在他可以根据合同了。 他可以确保他能够处理客户的请求并返回预期的响应。

服务开发人员通过合同知道他需要做什么,要执行什么行为。

契约可以更深入地集成到开发过程中。 您可以部署一个Pact-broker来聚合此类合同,支持它们的版本控制,并可以显示依赖关系图。



在构建客户端时,可以在步骤CI中将新生成的合同上载到代理。 并在服务器代码中通过URL指示合同的动态加载。 Spring Cloud Contract也支持这一点。

CDC适用性


消费者驱动合同的局限性是什么?

要使用此方法, 您必须使用附加工具(例如契约) 付费 。 合同本身是一个额外的工件,这是另一个抽象,必须仔细维护并有意识地对其应用工程实践。

它们不会代替e2e测试 ,因为存根仍然是存根-实际系统组件的模型,虽然可能有点,但并不符合实际情况。 通过它们,无法验证复杂的方案。

而且, CDC不能替代API功能测试 。 与普通的旧单元测试相比,它们的支持成本更高。 契约开发人员建议使用以下试探法-如果您删除合同并且这不会导致客户端出错或误解,则不需要此方法。 例如,如果客户端以相同的方式处理它们,则不必通过合同来绝对描述所有API错误代码。 换句话说,合同仅为服务描述对其客户重要的内容 。 不多,但不少。

太多的合同也使API的发展变得复杂。 每增加一份合同都是进行红色测试的机会 。 设计CDC的方式必须使每次失败测试都承载有用的语义负载,而语义负载要超过其支持成本。 例如,如果合同确定了对消费者无动于衷的某个文本字段的最小长度(他使用Toleran Reader技术),那么每次更改此最小值都会破坏合同,并破坏周围的人的神经。 这种检查需要转移到API本身的级别并根据限制的来源来实施。

结论


CDC通过明确描述集成行为来提高产品质量。 它可以帮助客户和服务开发人员达成共识,使您可以通过代码进行交谈。 但这是以增加工具,引入新的抽象和团​​队成员的额外行动为代价的。

同时,CDC工具和框架正在积极开发中,并且已经可以在您的项目上进行测试。 测试:)

在5月27日至28日举行的QualityConf会议上,Andrei Markelov 将讨论基于产品的测试技术,而Arthur Khineltsev将讨论监视高负载的前端,即使很小的错误的代价也要成千上万悲伤用户。

快来聊天吧!

Source: https://habr.com/ru/post/zh-CN452960/


All Articles