87. Spring Cloud Contract Verifier 简介

Spring Cloud Contract Verifier 支持基于 JVM 的应用程序的 Consumer 驱动 Contract(CDC)开发。它将 TDD 移至软件体系结构级别。

Spring Cloud Contract Verifier 随附Contract 定义语言(CDL)。Contract 定义用于产生以下资源:

  • 在 Client 端代码(* client tests *)上进行集成测试时,WireMock 将使用 JSON 存根定义。测试代码仍然必须是手工编写的,并且测试数据是由 Spring Cloud Contract Verifier 生成的。

  • 消息传递路由(如果您正在使用消息传递服务)。我们与 Spring Integration,Spring Cloud Stream,Spring AMQP 和 Apache Camel 集成。您还可以设置自己的集成。

  • 验收测试(在 JUnit 4,JUnit 5 或 Spock 中)用于验证 API 的服务器端实现是否符合 Contract 规定(* server tests *)。完整的测试由 Spring Cloud Contract Verifier 生成。

87.1 History

在成为 Spring Cloud Contract 之前,该项目名为Accurest。它是由_和Jakub Kubrynski从(codearte.io创建的。

0.1.0版本于 2015 年 1 月 26 日发布,并于1.0.0版本于 2016 年 2 月 29 日变得稳定。

87.2 为什么要使用 Contract 验证程序?

假设我们有一个包含多个微服务的系统:

Microservices Architecture

87.2.1 测试问题

如果我们想在左上角测试该应用程序以确定它是否可以与其他服务通信,则可以执行以下两项操作之一:

  • 部署所有微服务并执行端到端测试。

  • 在单元/集成测试中模拟其他微服务。

两者都有优点,也有很多缺点。

部署所有微服务并执行端到端测试

Advantages:

  • Simulates production.

  • 测试服务之间的真实通信。

Disadvantages:

  • 要测试一个微服务,我们必须部署 6 个微服务,几个数据库等。

  • 测试运行的环境被锁定为单个测试套件(在此期间其他任何人都无法运行测试)。

  • 他们需要很长时间才能运行。

  • 反馈在此过程中非常晚。

  • 他们很难调试。

在单元/集成测试中模拟其他微服务

Advantages:

  • 他们提供了非常快速的反馈。

  • 他们没有基础架构要求。

Disadvantages:

  • 服务的实现者创建的存根可能与现实无关。

  • 您可以通过测试并通过失败的生产。

为了解决上述问题,创建了带有 Stub Runner 的 Spring Cloud Contract Verifier。主要思想是为您提供非常快速的反馈,而无需构建整个微服务世界。如果您使用存根,则仅需要应用程序直接使用的应用程序。

Stubbed Services

Spring Cloud Contract Verifier 可以确保您使用的存根是由您正在调用的服务创建的。另外,如果可以使用它们,则表示它们已针对生产者方面进行了测试。简而言之,您可以信任这些存根。

87.3 Purposes

带有 Stub Runner 的 Spring Cloud Contract Verifier 的主要目的是:

  • 为了确保 WireMock/Messaging 存根(在开发 Client 端时使用)完全执行实际的服务器端实现。

  • 推广 ATDD 方法和微服务架构风格。

  • 提供一种发布 Contract 更改的方法,该更改在双方立即可见。

  • 生成要在服务器端使用的样板测试代码。

Tip

Spring Cloud Contract Verifier 的目的不是开始在 Contract 中编写业务功能。假设我们有一个欺诈检查的业务用例。如果用户可能出于 100 种不同的原因而成为欺诈者,那么我们假设您将创建 2 个 Contract,其中一个用于肯定案例,一个用于否定案例。Contract 测试用于测试应用程序之间的 Contract,而不是模拟完整的行为。

87.4 工作原理

本部分探讨了具有 Stub Runner 的 Spring Cloud Contract Verifier 的工作方式。

87.4.1 三秒钟的游览

这个非常简短的导览将介绍如何使用 Spring Cloud Contract:

您会发现游览here更长一些。

在生产者端

要开始使用 Spring Cloud Contract,请将具有 Groovy DSL 或 YAML 中表示的REST/个消息传递 Contract 的文件添加到由contractsDslDir属性设置的 Contract 目录中。默认情况下为$rootDir/src/test/resources/contracts

然后将 Spring Cloud Contract Verifier 依赖项和插件添加到您的构建文件中,如以下示例所示:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-verifier</artifactId>
	<scope>test</scope>
</dependency>

以下清单显示了如何添加插件,该插件应放在文件的 build/plugins 部分中:

<plugin>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-contract-maven-plugin</artifactId>
	<version>${spring-cloud-contract.version}</version>
	<extensions>true</extensions>
</plugin>

运行./mvnw clean install会自动生成测试,以验证应用程序是否符合添加的 Contract。默认情况下,测试在org.springframework.cloud.contract.verifier.tests.下生成。

由于尚不存在 Contract 描述的功能的实现,因此测试失败。

要使它们通过,您必须添加处理 HTTP 请求或消息的正确实现。另外,您必须为项目自动添加正确的基本测试类以用于自动生成的测试。该类由所有自动生成的测试扩展,并且应包含运行它们所需的所有设置(例如RestAssuredMockMvc控制器设置或消息传递测试设置)。

一旦实现和测试 Base Class 就位,测试就会通过,并且将应用程序和存根工件都构建并安装在本地 Maven 存储库中。现在可以合并更改,并且可以在在线存储库中发布应用程序和存根工件。

在 Consumer 方面

Spring Cloud Contract Stub Runner可用于集成测试中,以获取模拟实际服务的正在运行的 WireMock 实例或消息传递路由。

为此,将依赖项添加到Spring Cloud Contract Stub Runner,如以下示例所示:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
	<scope>test</scope>
</dependency>

您可以通过以下两种方式之一在 Maven 资源库中安装生产者端存根:

  • 通过签出生产者端存储库并添加 Contract 并通过运行以下命令来生成存根:
$ cd local-http-server-repo
$ ./mvnw clean install -DskipTests

Tip

由于生产者方 Contract 实施尚未到位,因此跳过了测试,因此自动生成的 Contract 测试失败。

  • 通过从远程存储库获取已经存在的生产者服务存根。为此,请将存根工件 ID 和工件存储库 URL 作为Spring Cloud Contract Stub Runner属性传递,如以下示例所示:
stubrunner:
  ids: 'com.example:http-server-dsl:+:stubs:8080'
  repositoryRoot: http://repo.spring.io/libs-snapshot

现在,您可以使用@AutoConfigureStubRunnerComments 测试类。在 Comments 中,为Spring Cloud Contract Stub Runner提供group-idartifact-id值,以为您运行协作者的存根,如以下示例所示:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment=WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"com.example:http-server-dsl:+:stubs:6565"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class LoanApplicationServiceTests {

Tip

从在线存储库下载存根时,请使用REMOTE stubsMode,而在脱机工作中则使用LOCAL

现在,在集成测试中,您可以接收预期由协作服务发出的 HTTP 响应或消息的存根版本。

87.4.2 三分钟游

这个简短的导览将逐步介绍如何使用 Spring Cloud Contract:

您可以找到更简短的导览here

在生产者端

要开始使用Spring Cloud Contract,请将具有 Groovy DSL 或 YAML 中表示的REST/个消息传递 Contract 的文件添加到由contractsDslDir属性设置的 Contract 目录中。默认情况下为$rootDir/src/test/resources/contracts

对于 HTTP 存根,Contract 定义了应针对给定请求返回的响应类型(考虑到 HTTP 方法,URL,Headers,状态码等)。以下示例显示了 Groovy DSL 中的 HTTP 存根如何收缩:

package contracts

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'PUT'
		url '/fraudcheck'
		body([
			   "client.id": $(regex('[0-9]{10}')),
			   loanAmount: 99999
		])
		headers {
			contentType('application/json')
		}
	}
	response {
		status OK()
		body([
			   fraudCheckStatus: "FRAUD",
			   "rejection.reason": "Amount too high"
		])
		headers {
			contentType('application/json')
		}
	}
}

YAML 中表示的同一 Contract 应类似于以下示例:

request:
  method: PUT
  url: /fraudcheck
  body:
    "client.id": 1234567890
    loanAmount: 99999
  headers:
    Content-Type: application/json
  matchers:
    body:
      - path: $.['client.id']
        type: by_regex
        value: "[0-9]{10}"
response:
  status: 200
  body:
    fraudCheckStatus: "FRAUD"
    "rejection.reason": "Amount too high"
  headers:
    Content-Type: application/json;charset=UTF-8

对于消息传递,您可以定义:

  • 可以定义 Importing 和输出消息(考虑发送消息的位置和位置,消息正文和 Headers)。

  • 收到消息后应调用的方法。

  • 调用时应触发消息的方法。

以下示例显示了用 Groovy DSL 表示的骆驼消息传递协定:

def contractDsl = Contract.make {
	label 'some_label'
	input {
		messageFrom('jms:delete')
		messageBody([
				bookName: 'foo'
		])
		messageHeaders {
			header('sample', 'header')
		}
		assertThat('bookWasDeleted()')
	}
}

以下示例显示了用 YAML 表示的同一 Contract:

label: some_label
input:
  messageFrom: jms:delete
  messageBody:
    bookName: 'foo'
  messageHeaders:
    sample: header
  assertThat: bookWasDeleted()

然后,您可以将 Spring Cloud Contract Verifier 依赖项和插件添加到您的构建文件中,如以下示例所示:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-verifier</artifactId>
	<scope>test</scope>
</dependency>

以下清单显示了如何添加插件,该插件应放在文件的 build/plugins 部分中:

<plugin>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-contract-maven-plugin</artifactId>
	<version>${spring-cloud-contract.version}</version>
	<extensions>true</extensions>
</plugin>

运行./mvnw clean install会自动生成测试,以验证应用程序是否符合添加的 Contract。默认情况下,生成的测试在org.springframework.cloud.contract.verifier.tests.下。

以下示例显示了一个自动生成的 HTTPContract 测试示例:

@Test
public void validate_shouldMarkClientAsFraud() throws Exception {
    // given:
        MockMvcRequestSpecification request = given()
                .header("Content-Type", "application/vnd.fraud.v1+json")
                .body("{\"client.id\":\"1234567890\",\"loanAmount\":99999}");

    // when:
        ResponseOptions response = given().spec(request)
                .put("/fraudcheck");

    // then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type")).matches("application/vnd.fraud.v1.json.*");
    // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['fraudCheckStatus']").matches("[A-Z]{5}");
        assertThatJson(parsedJson).field("['rejection.reason']").isEqualTo("Amount too high");
}

前面的示例使用 Spring 的MockMvc来运行测试。这是 HTTPContract 的默认测试模式。但是,也可以使用 JAX-RSClient 端和显式 HTTP 调用。 (为此,请将插件的testMode属性分别更改为JAX-RSEXPLICIT.)

从 2.1.0 版本开始,也可以使用在后台运行的RestAssuredWebTestClient`with Spring's reactive `WebTestClient。在使用基于Web-Flux的响应式应用程序时,特别推荐这样做。为了使用WebTestClient,请将testMode设置为WEBTESTCLIENT

这是在WEBTESTCLIENT测试模式下生成的测试示例:

[source,java,indent=0]
@Test
	public void validate_shouldRejectABeerIfTooYoung() throws Exception {
		// given:
			WebTestClientRequestSpecification request = given()
					.header("Content-Type", "application/json")
					.body("{\"age\":10}");

		// when:
			WebTestClientResponse response = given().spec(request)
					.post("/check");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
			assertThat(response.header("Content-Type")).matches("application/json.*");
		// and:
			DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
			assertThatJson(parsedJson).field("['status']").isEqualTo("NOT_OK");
	}

除了默认的 JUnit 4,您可以通过将插件testFramework属性设置为JUNIT5Spock来使用 JUnit 5 或 Spock 测试。

Tip

现在,您还可以基于 Contract 生成 WireMock 方案,方法是在 Contract 文件名的开头添加订单号,后跟下划线。

以下示例显示了在 Spock 中为消息存根 Contracts 自动生成的测试:

[source,groovy,indent=0]
given:
	 ContractVerifierMessage inputMessage = contractVerifierMessaging.create(
		\'\'\'{"bookName":"foo"}\'\'\',
		['sample': 'header']
	)

when:
	 contractVerifierMessaging.send(inputMessage, 'jms:delete')

then:
	 noExceptionThrown()
	 bookWasDeleted()

由于尚不存在 Contract 描述的功能的实现,因此测试失败。

要使它们通过,必须添加处理 HTTP 请求或消息的正确实现。另外,您必须为项目自动添加正确的基本测试类以用于自动生成的测试。该类由所有自动生成的测试扩展,并且应包含运行它们所需的所有设置(例如RestAssuredMockMvc控制器设置或消息传递测试设置)。

一旦实现和测试 Base Class 就位,测试就会通过,并且将应用程序和存根工件都构建并安装在本地 Maven 存储库中。有关将存根 jar 安装到本地存储库的信息显示在日志中,如以下示例所示:

[INFO] --- spring-cloud-contract-maven-plugin:1.0.0.BUILD-SNAPSHOT:generateStubs (default-generateStubs) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar
[INFO]
[INFO] --- maven-jar-plugin:2.6:jar (default-jar) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:1.5.5.BUILD-SNAPSHOT:repackage (default) @ http-server ---
[INFO]
[INFO] --- maven-install-plugin:2.5.2:install (default-install) @ http-server ---
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.jar
[INFO] Installing /some/path/http-server/pom.xml to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.pom
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar

现在,您可以合并更改,并在在线存储库中发布应用程序和存根工件。

Docker Project

为了在使用非 JVM 技术创建应用程序时启用 Contract,已创建springcloud/spring-cloud-contract Docker 映像。它包含一个项目,该项目会自动为 HTTPContract 生成测试并以EXPLICIT测试模式执行它们。然后,如果测试通过,它将生成 Wiremock 存根并将其发布到工件 Management 器(可选)。为了使用该映像,您可以将 Contract 挂载到/contracts目录中并设置一些环境变量。

在 Consumer 方面

Spring Cloud Contract Stub Runner可用于集成测试中,以获取模拟实际服务的正在运行的 WireMock 实例或消息传递路由。

首先,将依赖项添加到Spring Cloud Contract Stub Runner

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
	<scope>test</scope>
</dependency>

您可以通过以下两种方式之一在 Maven 资源库中安装生产者端存根:

  • 通过签出生产者端存储库并添加 Contract 并通过运行以下命令来生成存根:
$ cd local-http-server-repo
$ ./mvnw clean install -DskipTests

Note

由于生产者方 Contract 实施尚未到位,因此跳过了测试,因此自动生成的 Contract 测试失败。

  • 从远程存储库获取已经存在的生产者服务存根。为此,请将存根工件标识和工件存储库 UR1 作为Spring Cloud Contract Stub Runner属性传递,如以下示例所示:
stubrunner:
  ids: 'com.example:http-server-dsl:+:stubs:8080'
  repositoryRoot: http://repo.spring.io/libs-snapshot

现在,您可以使用@AutoConfigureStubRunnerComments 测试类。在 Comments 中,为Spring Cloud Contract Stub Runner提供group-idartifact-id以便为您运行协作者的存根,如以下示例所示:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment=WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"com.example:http-server-dsl:+:stubs:6565"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class LoanApplicationServiceTests {

Tip

从在线存储库下载存根时,请使用REMOTE stubsMode,而在脱机工作中则使用LOCAL

在集成测试中,您可以收到预期由协作服务发出的 HTTP 响应或消息的存根版本。您可以在构建日志中看到类似于以下内容的条目:

2016-07-19 14:22:25.403  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Desired version is + - will try to resolve the latest version
2016-07-19 14:22:25.438  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is 0.0.1-SNAPSHOT
2016-07-19 14:22:25.439  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolving artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT using remote repositories []
2016-07-19 14:22:25.451  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar
2016-07-19 14:22:25.465  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacking stub from JAR [URI: file:/path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar]
2016-07-19 14:22:25.475  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacked file to [/var/folders/0p/xwq47sq106x1_g3dtv6qfm940000gq/T/contracts100276532569594265]
2016-07-19 14:22:27.737  INFO 41050 --- [           main] o.s.c.c.stubrunner.StubRunnerExecutor    : All stubs are now running RunningStubs [namesAndPorts={com.example:http-server:0.0.1-SNAPSHOT:stubs=8080}]

87.4.3 定义 Contract

作为服务的使用者,我们需要定义要实现的目标。我们需要制定我们的期望。这就是为什么我们签订 Contract 的原因。

假设您要发送一个包含 Client 公司 ID 以及它要向我们借入的金额的请求。您还希望通过 PUT 方法将其发送到/ fraudcheck URL。

Groovy DSL.

package contracts

org.springframework.cloud.contract.spec.Contract.make {
	request { // (1)
		method 'PUT' // (2)
		url '/fraudcheck' // (3)
		body([ // (4)
			   "client.id": $(regex('[0-9]{10}')),
			   loanAmount: 99999
		])
		headers { // (5)
			contentType('application/json')
		}
	}
	response { // (6)
		status OK() // (7)
		body([ // (8)
			   fraudCheckStatus: "FRAUD",
			   "rejection.reason": "Amount too high"
		])
		headers { // (9)
			contentType('application/json')
		}
	}
}

/*
From the Consumer perspective, when shooting a request in the integration test:

(1) - If the consumer sends a request
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
 * has a field `client.id` that matches a regular expression `[0-9]{10}`
 * has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/json`
(6) - then the response will be sent with
(7) - status equal `200`
(8) - and JSON body equal to
 { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` equal to `application/json`

From the Producer perspective, in the autogenerated producer-side test:

(1) - A request will be sent to the producer
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
 * has a field `client.id` that will have a generated value that matches a regular expression `[0-9]{10}`
 * has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/json`
(6) - then the test will assert if the response has been sent with
(7) - status equal `200`
(8) - and JSON body equal to
 { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` matching `application/json.*`
 */

YAML.

request: # (1)
  method: PUT # (2)
  url: /fraudcheck # (3)
  body: # (4)
    "client.id": 1234567890
    loanAmount: 99999
  headers: # (5)
    Content-Type: application/json
  matchers:
    body:
      - path: $.['client.id'] # (6)
        type: by_regex
        value: "[0-9]{10}"
response: # (7)
  status: 200 # (8)
  body:  # (9)
    fraudCheckStatus: "FRAUD"
    "rejection.reason": "Amount too high"
  headers: # (10)
    Content-Type: application/json;charset=UTF-8

#From the Consumer perspective, when shooting a request in the integration test:
#
#(1) - If the consumer sends a request
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a field `client.id`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(6) - and a `client.id` json entry matches the regular expression `[0-9]{10}`
#(7) - then the response will be sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json`
#
#From the Producer perspective, in the autogenerated producer-side test:
#
#(1) - A request will be sent to the producer
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a field `client.id` `1234567890`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(7) - then the test will assert if the response has been sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json;charset=UTF-8`

87.4.4Client 端

Spring Cloud Contract 会生成存根,您可以在 Client 端测试期间使用该存根。您将获得一个模拟该服务的运行中的 WireMock 实例/消息传递路由。您想使用适当的存根定义来提供该实例。

在某个时间点,您需要向欺诈检测服务发送请求。

ResponseEntity<FraudServiceResponse> response =
		restTemplate.exchange("http://localhost:" + port + "/fraudcheck", HttpMethod.PUT,
				new HttpEntity<>(request, httpHeaders),
				FraudServiceResponse.class);

@AutoConfigureStubRunnerComments 测试类。在 Comments 中,为存根运行器提供组 ID 和工件 ID,以下载协作者的存根。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"com.example:http-server-dsl:+:stubs:6565"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class LoanApplicationServiceTests {

之后,在测试期间,Spring Cloud Contract 将自动在 Maven 存储库中找到存根(模拟真实服务),并将其暴露在已配置(或随机)的端口上。

87.4.5 服务器端

由于您正在开发存根,因此需要确保它实际上类似于您的具体实现。您不能存在存根以一种方式运行而应用程序以不同方式运行的情况,尤其是在生产环境中。

为确保您的应用程序符合您在存根中定义的方式,将从提供的存根中生成测试。

自动生成的测试或多或少看起来像这样:

@Test
public void validate_shouldMarkClientAsFraud() throws Exception {
    // given:
        MockMvcRequestSpecification request = given()
                .header("Content-Type", "application/vnd.fraud.v1+json")
                .body("{\"client.id\":\"1234567890\",\"loanAmount\":99999}");

    // when:
        ResponseOptions response = given().spec(request)
                .put("/fraudcheck");

    // then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type")).matches("application/vnd.fraud.v1.json.*");
    // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['fraudCheckStatus']").matches("[A-Z]{5}");
        assertThatJson(parsedJson).field("['rejection.reason']").isEqualTo("Amount too high");
}

87.5Consumer 主导 Contract(CDC)分步指南

考虑欺诈检测和贷款发行过程的示例。业务场景是这样的,我们希望向人们发放贷款,但又不想他们从我们那里窃取资金。我们系统当前的实施情况是向所有人提供贷款。

假设Loan IssuanceFraud Detection服务器的 Client 端。在当前的 sprint 中,我们必须开发一个新功能:如果 Client 想要借太多钱,那么我们会将 Client 标记为欺诈。

技术说明-欺诈检测的artifact-idhttp-server,而贷款发行的工件 ID 为http-client,并且两者的group-idcom.example

社交 Comment-Client 和服务器开发团队都需要在整个过程中直接沟通并讨论更改。 CDC 就是关于沟通的。

服务器端代码在这里可用Client 端代码在这里

Tip

在这种情况下,生产者拥有 Contract。实际上,所有 Contract 都在生产者的存储库中。

87.5.1 技术说明

如果使用 SNAPSHOT / Milestone / Release Candidate 版本,请在构建中添加以下部分:

Maven.

<repositories>
	<repository>
		<id>spring-snapshots</id>
		<name>Spring Snapshots</name>
		<url>https://repo.spring.io/snapshot</url>
		<snapshots>
			<enabled>true</enabled>
		</snapshots>
	</repository>
	<repository>
		<id>spring-milestones</id>
		<name>Spring Milestones</name>
		<url>https://repo.spring.io/milestone</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</repository>
	<repository>
		<id>spring-releases</id>
		<name>Spring Releases</name>
		<url>https://repo.spring.io/release</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</repository>
</repositories>
<pluginRepositories>
	<pluginRepository>
		<id>spring-snapshots</id>
		<name>Spring Snapshots</name>
		<url>https://repo.spring.io/snapshot</url>
		<snapshots>
			<enabled>true</enabled>
		</snapshots>
	</pluginRepository>
	<pluginRepository>
		<id>spring-milestones</id>
		<name>Spring Milestones</name>
		<url>https://repo.spring.io/milestone</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</pluginRepository>
	<pluginRepository>
		<id>spring-releases</id>
		<name>Spring Releases</name>
		<url>https://repo.spring.io/release</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</pluginRepository>
</pluginRepositories>

Gradle.

repositories {
	mavenCentral()
	mavenLocal()
	maven { url "http://repo.spring.io/snapshot" }
	maven { url "http://repo.spring.io/milestone" }
	maven { url "http://repo.spring.io/release" }
}

87.5.2Consumer 方(发放贷款)

作为贷款发行服务的开发人员(欺诈检测服务器的使用者),您可以执行以下步骤:

  • 通过为您的功能编写测试来开始进行 TDD。

  • 编写缺少的实现。

  • 在本地克隆欺诈检测服务存储库。

  • 在欺诈检测服务的仓库中本地定义 Contract。

  • 添加 Spring Cloud Contract Verifier 插件。

  • 运行集成测试。

  • 提出拉取请求。

  • 创建一个初始实现。

  • 接管请求请求。

  • 编写缺少的实现。

  • 部署您的应用程序。

  • Work online.

通过为您的功能编写测试来开始进行 TDD.

@Test
public void shouldBeRejectedDueToAbnormalLoanAmount() {
	// given:
	LoanApplication application = new LoanApplication(new Client("1234567890"),
			99999);
	// when:
	LoanApplicationResult loanApplication = service.loanApplication(application);
	// then:
	assertThat(loanApplication.getLoanApplicationStatus())
			.isEqualTo(LoanApplicationStatus.LOAN_APPLICATION_REJECTED);
	assertThat(loanApplication.getRejectionReason()).isEqualTo("Amount too high");
}

假设您已经编写了新功能的测试。如果收到大量贷款申请,则系统应拒绝该贷款申请并提供一些说明。

写出缺少的实现

在某个时间点,您需要向欺诈检测服务发送请求。假设您需要发送包含 ClientID 和 Client 希望借入的金额的请求。您想通过PUT方法将其发送到/fraudcheck网址。

ResponseEntity<FraudServiceResponse> response =
		restTemplate.exchange("http://localhost:" + port + "/fraudcheck", HttpMethod.PUT,
				new HttpEntity<>(request, httpHeaders),
				FraudServiceResponse.class);

为简单起见,欺诈检测服务的端口设置为8080,并且该应用程序在8090上运行。

如果此时开始测试,则会中断测试,因为当前没有服务在端口8080上运行。

在本地克隆欺诈检测服务存储库.

您可以从服务器端 Contract 开始。为此,您必须首先克隆它。

$ git clone https://your-git-server.com/server-side.git local-http-server-repo

在欺诈检测服务的 repo 中本地定义 Contract.

作为 Consumer,您需要定义要实现的目标。您需要制定自己的期望。为此,请编写以下 Contract:

Tip

将 Contract 放在src/test/resources/contracts/fraud文件夹下。 fraud文件夹很重要,因为生产者的测试 Base Class 名称引用了该文件夹。

Groovy DSL.

package contracts

org.springframework.cloud.contract.spec.Contract.make {
	request { // (1)
		method 'PUT' // (2)
		url '/fraudcheck' // (3)
		body([ // (4)
			   "client.id": $(regex('[0-9]{10}')),
			   loanAmount: 99999
		])
		headers { // (5)
			contentType('application/json')
		}
	}
	response { // (6)
		status OK() // (7)
		body([ // (8)
			   fraudCheckStatus: "FRAUD",
			   "rejection.reason": "Amount too high"
		])
		headers { // (9)
			contentType('application/json')
		}
	}
}

/*
From the Consumer perspective, when shooting a request in the integration test:

(1) - If the consumer sends a request
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
 * has a field `client.id` that matches a regular expression `[0-9]{10}`
 * has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/json`
(6) - then the response will be sent with
(7) - status equal `200`
(8) - and JSON body equal to
 { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` equal to `application/json`

From the Producer perspective, in the autogenerated producer-side test:

(1) - A request will be sent to the producer
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
 * has a field `client.id` that will have a generated value that matches a regular expression `[0-9]{10}`
 * has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/json`
(6) - then the test will assert if the response has been sent with
(7) - status equal `200`
(8) - and JSON body equal to
 { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` matching `application/json.*`
 */

YAML.

request: # (1)
  method: PUT # (2)
  url: /fraudcheck # (3)
  body: # (4)
    "client.id": 1234567890
    loanAmount: 99999
  headers: # (5)
    Content-Type: application/json
  matchers:
    body:
      - path: $.['client.id'] # (6)
        type: by_regex
        value: "[0-9]{10}"
response: # (7)
  status: 200 # (8)
  body:  # (9)
    fraudCheckStatus: "FRAUD"
    "rejection.reason": "Amount too high"
  headers: # (10)
    Content-Type: application/json;charset=UTF-8

#From the Consumer perspective, when shooting a request in the integration test:
#
#(1) - If the consumer sends a request
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a field `client.id`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(6) - and a `client.id` json entry matches the regular expression `[0-9]{10}`
#(7) - then the response will be sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json`
#
#From the Producer perspective, in the autogenerated producer-side test:
#
#(1) - A request will be sent to the producer
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a field `client.id` `1234567890`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(7) - then the test will assert if the response has been sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json;charset=UTF-8`

YMLContract 很简单。但是,当您查看使用静态类型的 Groovy DSL 编写的 Contract 时-您可能想知道value(client(…), server(…))部分是什么。通过使用此表示法,Spring Cloud Contract 可让您定义 JSON 块,URL 等动态的部分。如果是标识符或时间戳,则无需对值进行硬编码。您要允许一些不同的值范围。要启用值范围,可以为使用者方设置与这些值匹配的正则表达式。您可以通过 Map 符号或带插值的字符串来提供主体。请参阅第 93 章,ContractDSL部分以获取更多信息。我们强烈建议您使用 Map 符号!

Tip

您必须了解 Map 符号才能设置 Contract。请阅读关于 JSON 的 Groovy 文档

先前显示的 Contract 是双方之间的协议,其中:

  • 如果 HTTP 请求与所有

  • /fraudcheck端点上的PUT方法,

    • 具有client.id且与正则表达式[0-9]{10}loanAmount等于99999匹配的 JSON 正文,

    • Content-TypeHeaders,其值为application/vnd.fraud.v1+json

  • 然后将 HTTP 响应发送给使用者

  • 状态为200

    • 包含 JSON 正文,其中fraudCheckStatus字段的值FRAUDrejectionReason字段的值Amount too high

    • Content-TypeHeaders,值为application/vnd.fraud.v1+json

一旦准备好在集成测试中实际检查 API,就需要在本地安装存根。

添加 Spring Cloud Contract Verifier 插件.

我们可以添加 Maven 或 Gradle 插件。在此示例中,您将看到如何添加 Maven。首先,添加Spring Cloud Contract BOM。

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-dependencies</artifactId>
			<version>${spring-cloud-release.version}</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

接下来,添加Spring Cloud Contract Verifier Maven 插件

<plugin>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-contract-maven-plugin</artifactId>
	<version>${spring-cloud-contract.version}</version>
	<extensions>true</extensions>
	<configuration>
		<packageWithBaseClasses>com.example.fraud</packageWithBaseClasses>
		<convertToYaml>true</convertToYaml>
	</configuration>
</plugin>

自从添加了插件以来,您就可以从提供的 Contract 中获得Spring Cloud Contract Verifier功能:

  • 生成并运行测试

  • 制作并安装存根

您不想生成测试,因为作为 Consumer,您只想玩存根。您需要跳过测试的生成和执行。执行时:

$ cd local-http-server-repo
$ ./mvnw clean install -DskipTests

在日志中,您将看到以下内容:

[INFO] --- spring-cloud-contract-maven-plugin:1.0.0.BUILD-SNAPSHOT:generateStubs (default-generateStubs) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar
[INFO]
[INFO] --- maven-jar-plugin:2.6:jar (default-jar) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:1.5.5.BUILD-SNAPSHOT:repackage (default) @ http-server ---
[INFO]
[INFO] --- maven-install-plugin:2.5.2:install (default-install) @ http-server ---
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.jar
[INFO] Installing /some/path/http-server/pom.xml to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.pom
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar

以下行非常重要:

[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar

它确认http-server的存根已安装在本地存储库中。

运行集成测试.

为了从自动存根下载的 Spring Cloud Contract Stub Runner 功能中获利,您必须在用户端项目(Loan Application service)中执行以下操作:

添加Spring Cloud Contract BOM:

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-dependencies</artifactId>
			<version>${spring-cloud-release-train.version}</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

将依赖项添加到Spring Cloud Contract Stub Runner

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
	<scope>test</scope>
</dependency>

@AutoConfigureStubRunnerComments 测试类。在 Comments 中,为 Stub Runner 提供group-idartifact-id以便下载协作者的 Stub。 (可选步骤)由于您是与离线协作者一起玩,因此您还可以提供离线工作开关(StubRunnerProperties.StubsMode.LOCAL)。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"com.example:http-server-dsl:+:stubs:6565"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class LoanApplicationServiceTests {

现在,当您运行测试时,您将看到类似以下的内容:

2016-07-19 14:22:25.403  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Desired version is + - will try to resolve the latest version
2016-07-19 14:22:25.438  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is 0.0.1-SNAPSHOT
2016-07-19 14:22:25.439  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolving artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT using remote repositories []
2016-07-19 14:22:25.451  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar
2016-07-19 14:22:25.465  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacking stub from JAR [URI: file:/path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar]
2016-07-19 14:22:25.475  INFO 41050 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacked file to [/var/folders/0p/xwq47sq106x1_g3dtv6qfm940000gq/T/contracts100276532569594265]
2016-07-19 14:22:27.737  INFO 41050 --- [           main] o.s.c.c.stubrunner.StubRunnerExecutor    : All stubs are now running RunningStubs [namesAndPorts={com.example:http-server:0.0.1-SNAPSHOT:stubs=8080}]

此输出意味着 Stub Runner 已找到您的存根,并为您的应用启动了服务器,该服务器的组 ID 为com.example,工件 ID 为http-server,存根的版本为0.0.1-SNAPSHOT,端口8080上为stubs分类器。

提出拉取请求.

到目前为止,您所做的是一个迭代过程。您可以试用 Contract,将其安装在本地,然后在用户端工作,直到 Contract 按您的意愿运行。

对结果满意并通过测试后,将拉取请求发布到服务器端。目前,Consumer 方面的工作已经完成。

87.5.3 生产方(欺诈检测服务器)

作为欺诈检测服务器(贷款发放服务的服务器)的开发人员:

创建初始实施.

提醒一下,您可以在此处看到初始实现:

@RequestMapping(value = "/fraudcheck", method = PUT)
public FraudCheckResult fraudCheck(@RequestBody FraudCheck fraudCheck) {
return new FraudCheckResult(FraudCheckStatus.OK, NO_REASON);
}

接受拉取请求.

$ git checkout -b contract-change-pr master
$ git pull https://your-git-server.com/server-side-fork.git contract-change-pr

您必须添加自动生成的测试所需的依赖项:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-contract-verifier</artifactId>
	<scope>test</scope>
</dependency>

在 Maven 插件的配置中,传递packageWithBaseClasses属性

<plugin>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-contract-maven-plugin</artifactId>
	<version>${spring-cloud-contract.version}</version>
	<extensions>true</extensions>
	<configuration>
		<packageWithBaseClasses>com.example.fraud</packageWithBaseClasses>
		<convertToYaml>true</convertToYaml>
	</configuration>
</plugin>

Tip

本示例通过设置packageWithBaseClasses属性使用“基于约定”的命名。这样做意味着最后两个软件包组合在一起以成为基础测试类的名称。在我们的案例中,Contract 位于src/test/resources/contracts/fraud下。由于您没有从contracts文件夹开始的两个软件包,因此仅选择一个,应该为fraud。添加Base后缀并大写fraud。这为您提供FraudBase测试类名称。

所有生成的测试都扩展了该类。在那儿,您可以设置您的 Spring Context 或任何必要的东西。在这种情况下,使用放心的 MVC启动服务器端FraudDetectionController

package com.example.fraud;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;

public class FraudBase {
	@Before
	public void setup() {
		RestAssuredMockMvc.standaloneSetup(new FraudDetectionController(),
				new FraudStatsController(stubbedStatsProvider()));
	}

	private StatsProvider stubbedStatsProvider() {
		return fraudType -> {
			switch (fraudType) {
			case DRUNKS:
				return 100;
			case ALL:
				return 200;
			}
			return 0;
		};
	}

	public void assertThatRejectionReasonIsNull(Object rejectionReason) {
		assert rejectionReason == null;
	}
}

现在,如果您运行./mvnw clean install,则会得到以下信息:

Results :

Tests in error:
  ContractVerifierTest.validate_shouldMarkClientAsFraud:32 » IllegalState Parsed...

发生此错误的原因是您有一个新 Contract,该 Contract 是从中生成测试的,并且由于未实现该功能而失败。自动生成的测试如下所示:

@Test
public void validate_shouldMarkClientAsFraud() throws Exception {
    // given:
        MockMvcRequestSpecification request = given()
                .header("Content-Type", "application/vnd.fraud.v1+json")
                .body("{\"client.id\":\"1234567890\",\"loanAmount\":99999}");

    // when:
        ResponseOptions response = given().spec(request)
                .put("/fraudcheck");

    // then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type")).matches("application/vnd.fraud.v1.json.*");
    // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['fraudCheckStatus']").matches("[A-Z]{5}");
        assertThatJson(parsedJson).field("['rejection.reason']").isEqualTo("Amount too high");
}

如果您使用了 Groovy DSL,则可以看到,存在于value(consumer(…), producer(…))块中的所有 Contract 的producer()部分都已注入到测试中。如果使用 YAML,则对responsematchers部分同样适用。

请注意,在生产者方面,您也在执行 TDD。期望以测试的形式表达。此测试使用 Contract 中定义的 URL,Headers 和正文向我们自己的应用程序发送请求。它还期望响应中精确定义的值。换句话说,您拥有redgreenrefactorred部分。现在是时候将red转换为green了。

写出缺少的实现

因为您知道预期的 Importing 和预期的输出,所以可以编写缺少的实现:

@RequestMapping(value = "/fraudcheck", method = PUT)
public FraudCheckResult fraudCheck(@RequestBody FraudCheck fraudCheck) {
if (amountGreaterThanThreshold(fraudCheck)) {
	return new FraudCheckResult(FraudCheckStatus.FRAUD, AMOUNT_TOO_HIGH);
}
return new FraudCheckResult(FraudCheckStatus.OK, NO_REASON);
}

当您再次执行./mvnw clean install时,测试通过。由于Spring Cloud Contract Verifier插件将测试添加到generated-test-sources,因此您实际上可以从 IDE 运行这些测试。

部署您的应用.

完成工作后,即可部署更改。首先,合并分支:

$ git checkout master
$ git merge --no-ff contract-change-pr
$ git push origin master

您的 CI 可能会运行./mvnw clean deploy之类的东西,它将同时发布应用程序和存根工件。

87.5.4Consumer 方(贷款发行)最后一步

作为贷款发行服务的开发人员(欺诈检测服务器的使用者):

将分支合并到主机.

$ git checkout master
$ git merge --no-ff contract-change-pr

Work online.

现在,您可以禁用 Spring Cloud Contract Stub Runner 的脱机工作,并指定包含您的存根的存储库所在的位置。此时,服务器端的存根会自动从 Nexus/Artifactory 下载。您可以将stubsMode的值设置为REMOTE。以下代码显示了通过更改属性来实现相同目的的示例。

stubrunner:
  ids: 'com.example:http-server-dsl:+:stubs:8080'
  repositoryRoot: http://repo.spring.io/libs-snapshot

That's it!

87.6 Dependencies

添加依赖项的最佳方法是使用适当的starter依赖项。

对于stub-runner,请使用spring-cloud-starter-stub-runner。使用插件时,添加spring-cloud-starter-contract-verifier

87.7 其他链接

这里有一些与 Spring Cloud Contract Verifier 和 Stub Runner 相关的资源。请注意,有些可能已经过时,因为 Spring Cloud Contract Verifier 项目正在不断开发中。

87.7.1 Spring Cloud Contract 视频

您可以从 Warsaw JUG 观看有关 Spring Cloud Contract 的视频:

87.7.2 Readings

87.8 Samples

您可以在samples找到一些示例。