93. Contract DSL

Spring Cloud Contract 支持开箱即用的 2 种 DSL 类型。一则用Groovy写,另一则用YAML写。

如果您决定使用 Groovy 编写 Contract,那么如果您以前没有使用过 Groovy,请不要感到惊慌。true 不需要该语言的知识,因为 Contract DSL 仅使用它的一小部分(仅 Literals,方法调用和闭包)。而且,DSL 是静态类型的,以使其在不了解 DSL 本身的情况下也可供程序员阅读。

Tip

请记住,在 GroovyContract 文件中,必须为Contract类和make静态导入(例如org.springframework.cloud.spec.Contract.make { … })提供完全限定的名称。您还可以提供对Contract类的导入:import org.springframework.cloud.spec.Contract,然后调用Contract.make { … }

Tip

Spring Cloud Contract 支持在一个文件中定义多个 Contract。

以下是 GroovyContract 定义的完整示例:


以下是 YAMLContract 定义的完整示例:

description: Some description
name: some name
priority: 8
ignored: true
request:
  url: /foo
  queryParameters:
    a: b
    b: c
  method: PUT
  headers:
    foo: bar
    fooReq: baz
  body:
    foo: bar
  matchers:
    body:
      - path: $.foo
        type: by_regex
        value: bar
    headers:
      - key: foo
        regex: bar
response:
  status: 200
  headers:
    foo2: bar
    foo3: foo33
    fooRes: baz
  body:
    foo2: bar
    foo3: baz
    nullValue: null
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
      - path: $.nullValue
        type: by_null
        value: null
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)

Tip

您可以使用独立的 maven 命令将 Contract 编译为存根 Map:mvn org.springframework.cloud:spring-cloud-contract-maven-plugin:convert

93.1 Limitations

Warning

Spring Cloud Contract Verifier 不正确支持 XML。请使用 JSON 或帮助我们实现此功能。

Warning

验证 JSON 数组大小的支持是实验性的。如果要打开它,请将以下系统属性的值设置为truespring.cloud.contract.verifier.assert.size。默认情况下,此功能设置为false。您还可以在插件配置中提供assertJsonSize属性。

Warning

因为 JSON 结构可以具有任何形式,所以当使用 Groovy DSL 和GString中的value(consumer(…), producer(…))表示法时,可能无法正确解析它。这就是为什么您应该使用 Groovy Map 表示法的原因。

93.2 常见的顶级元素

以下各节描述了最常见的顶级元素:

93.2.1 Description

您可以在 Contract 中添加description。描述是任意文本。以下代码显示了一个示例:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
			description('''
given:
	An input
when:
	Sth happens
then:
	Output
''')
		}

YAML.

description: Some description
name: some name
priority: 8
ignored: true
request:
  url: /foo
  queryParameters:
    a: b
    b: c
  method: PUT
  headers:
    foo: bar
    fooReq: baz
  body:
    foo: bar
  matchers:
    body:
      - path: $.foo
        type: by_regex
        value: bar
    headers:
      - key: foo
        regex: bar
response:
  status: 200
  headers:
    foo2: bar
    foo3: foo33
    fooRes: baz
  body:
    foo2: bar
    foo3: baz
    nullValue: null
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
      - path: $.nullValue
        type: by_null
        value: null
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)

93.2.2 Name

您可以为 Contract 提供名称。假设您提供了以下名称:should register a user。如果这样做,则自动生成的测试的名称为validate_should_register_a_user。另外,WireMock 存根中的存根名称为should_register_a_user.json

Tip

您必须确保该名称不包含使生成的测试无法编译的任何字符。另外,请记住,如果为多个 Contract 提供相同的名称,则自动生成的测试将无法编译,并且生成的存根会相互覆盖。

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	name("some_special_name")
}

YAML.

name: some name

93.2.3 忽略 Contract

如果要忽略 Contract,则可以在插件配置中设置忽略 Contract 的值,也可以在 Contract 本身上设置ignored属性:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	ignored()
}

YAML.

ignored: true

93.2.4 从文件传递值

从版本1.2.0开始,您可以传递文件中的值。假设您在我们的项目中拥有以下资源。

└── src
 └── test
   └── resources
     └── contracts
             ├── readFromFile.groovy
             ├── request.json
             └── response.json

进一步假设您的 Contract 如下:

Groovy DSL.

import org.springframework.cloud.contract.spec.Contract

Contract.make {
	request {
		method('PUT')
		headers {
			contentType(applicationJson())
		}
		body(file("request.json"))
		url("/1")
	}
	response {
		status OK()
		body(file("response.json"))
		headers {
			contentType(applicationJson())
		}
	}
}

YAML.

request:
  method: GET
  url: /foo
  bodyFromFile: request.json
response:
  status: 200
  bodyFromFile: response.json

进一步假设 JSON 文件如下:

request.json

{
  "status": "REQUEST"
}

response.json

{
  "status": "RESPONSE"
}

当进行测试或存根生成时,文件的内容将传递到请求或响应的主体。文件名必须是相对于 Contract 所在文件夹的位置的文件。

如果您需要以二进制形式传递文件的内容,则足以在 Groovy DSL 中使用fileAsBytes方法或在 YAML 中使用bodyFromFileAsBytes字段。

Groovy DSL.

import org.springframework.cloud.contract.spec.Contract

Contract.make {
	request {
		url("/1")
		method(PUT())
		headers {
			contentType(applicationOctetStream())
		}
		body(fileAsBytes("request.pdf"))
	}
	response {
		status 200
		body(fileAsBytes("response.pdf"))
		headers {
			contentType(applicationOctetStream())
		}
	}
}

YAML.

request:
    url: /1
    method: PUT
    headers:
        Content-Type: application/octet-stream
    bodyFromFileAsBytes: request.pdf
response:
    status: 200
    bodyFromFileAsBytes: response.pdf
    headers:
        Content-Type: application/octet-stream

Tip

每当您要使用 HTTP 和消息传递的二进制有效负载时,都应使用此方法。

93.2.5 HTTP 顶级元素

在 Contract 定义的顶级闭合中可以调用以下方法。 requestresponse为必填项。 priority是可选的。

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	// Definition of HTTP request part of the contract
	// (this can be a valid request or invalid depending
	// on type of contract being specified).
	request {
		method GET()
		url "/foo"
		//...
	}

	// Definition of HTTP response part of the contract
	// (a service implementing this contract should respond
	// with following response after receiving request
	// specified in "request" part above).
	response {
		status 200
		//...
	}

	// Contract priority, which can be used for overriding
	// contracts (1 is highest). Priority is optional.
	priority 1
}

YAML.

priority: 8
request:
...
response:
...

Tip

如果要使 Contract 的优先级值较高,则需要将**较低的数字传递给priority标签/方法。例如。值5priority的优先级高于值10priority

93.3 Request

HTTP 协议只需要在请求中指定 方法和 url 即可。在 Contract 的请求定义中,必须提供相同的信息。

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		// HTTP request method (GET/POST/PUT/DELETE).
		method 'GET'

		// Path component of request URL is specified as follows.
		urlPath('/users')
	}

	response {
		//...
		status 200
	}
}

YAML.

method: PUT
url: /foo

可以指定绝对值url而不是相对值url,但是建议使用urlPath,因为这样做会使测试 独立于主机

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'GET'

		// Specifying `url` and `urlPath` in one contract is illegal.
		url('http://localhost:8888/users')
	}

	response {
		//...
		status 200
	}
}

YAML.

request:
  method: PUT
  urlPath: /foo

request可能包含 查询参数

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
		method GET()

		urlPath('/users') {

			// Each parameter is specified in form
			// `'paramName' : paramValue` where parameter value
			// may be a simple literal or one of matcher functions,
			// all of which are used in this example.
			queryParameters {

				// If a simple literal is used as value
				// default matcher function is used (equalTo)
				parameter 'limit': 100

				// `equalTo` function simply compares passed value
				// using identity operator (==).
				parameter 'filter': equalTo("email")

				// `containing` function matches strings
				// that contains passed substring.
				parameter 'gender': value(consumer(containing("[mf]")), producer('mf'))

				// `matching` function tests parameter
				// against passed regular expression.
				parameter 'offset': value(consumer(matching("[0-9]+")), producer(123))

				// `notMatching` functions tests if parameter
				// does not match passed regular expression.
				parameter 'loginStartsWith': value(consumer(notMatching(".{0,2}")), producer(3))
			}
		}

		//...
	}

	response {
		//...
		status 200
	}
}

YAML.

request:
...
  queryParameters:
    a: b
    b: c
  headers:
    foo: bar
    fooReq: baz
  cookies:
    foo: bar
    fooReq: baz
  body:
    foo: bar
  matchers:
    body:
      - path: $.foo
        type: by_regex
        value: bar
    headers:
      - key: foo
        regex: bar
response:
  status: 200
  fixedDelayMilliseconds: 1000
  headers:
    foo2: bar
    foo3: foo33
    fooRes: baz
  body:
    foo2: bar
    foo3: baz
    nullValue: null
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
      - path: $.nullValue
        type: by_null
        value: null
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)
    cookies:
      - key: foo2
        regex: bar
      - key: foo3
        predefined:

request可能包含其他 requestHeaders ,如以下示例所示:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
		method GET()
		url "/foo"

		// Each header is added in form `'Header-Name' : 'Header-Value'`.
		// there are also some helper methods
		headers {
			header 'key': 'value'
			contentType(applicationJson())
		}

		//...
	}

	response {
		//...
		status 200
	}
}

YAML.

request:
...
headers:
  foo: bar
  fooReq: baz

request可能包含其他 request cookie ,如以下示例所示:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
		method GET()
		url "/foo"

		// Each Cookies is added in form `'Cookie-Key' : 'Cookie-Value'`.
		// there are also some helper methods
		cookies {
			cookie 'key': 'value'
			cookie('another_key', 'another_value')
		}

		//...
	}

	response {
		//...
		status 200
	}
}

YAML.

request:
...
cookies:
  foo: bar
  fooReq: baz

request可能包含 请求正文

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
		method GET()
		url "/foo"

		// Currently only JSON format of request body is supported.
		// Format will be determined from a header or body's content.
		body '''{ "login" : "john", "name": "John The Contract" }'''
	}

	response {
		//...
		status 200
	}
}

YAML.

request:
...
body:
  foo: bar

request可能包含 multipart 元素。要包含 Multipart 元素,请使用multipart方法/部分,如以下示例所示

Groovy DSL.


YAML.

request:
  method: PUT
  url: /multipart
  headers:
    Content-Type: multipart/form-data;boundary=AaB03x
  multipart:
    params:
    # key (parameter name), value (parameter value) pair
      formParameter: '"formParameterValue"'
      someBooleanParameter: true
    named:
      - paramName: file
        fileName: filename.csv
        fileContent: file content
  matchers:
    multipart:
      params:
        - key: formParameter
          regex: ".+"
        - key: someBooleanParameter
          predefined: any_boolean
      named:
        - paramName: file
          fileName:
            predefined: non_empty
          fileContent:
            predefined: non_empty
response:
  status: 200

在前面的示例中,我们以两种方式之一定义参数:

Groovy DSL

  • 直接使用 Map 符号,其中值可以是动态属性(例如formParameter: $(consumer(…), producer(…)))。

  • 通过使用named(…)方法,您可以设置命名参数。命名参数可以设置namecontent。您可以通过带有两个参数的方法(例如named("fileName", "fileContent"))或通过 Map 符号(例如named(name: "fileName", content: "fileContent"))来调用它。

YAML

  • 通过multipart.params部分设置 Multipart 参数

  • 可以通过multipart.named部分设置命名参数(给定参数名称的fileNamefileContent)。该部分包含paramName(参数名称),fileName(文件名称),fileContent(文件内容)字段

  • 可以通过matchers.multipart部分设置动态位

  • 对于参数,请使用可以接受regexpredefined正则表达式的params部分

    • 对于命名参数,请使用named部分,其中首先通过paramName定义参数名称,然后可以通过regexpredefined正则表达式传递fileNamefileContent的参数化

根据该 Contract,生成的测试如下:

// given:
 MockMvcRequestSpecification request = given()
   .header("Content-Type", "multipart/form-data;boundary=AaB03x")
   .param("formParameter", "\"formParameterValue\"")
   .param("someBooleanParameter", "true")
   .multiPart("file", "filename.csv", "file content".getBytes());

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

// then:
 assertThat(response.statusCode()).isEqualTo(200);

WireMock 存根如下:

'''
{
  "request" : {
	"url" : "/multipart",
	"method" : "PUT",
	"headers" : {
	  "Content-Type" : {
		"matches" : "multipart/form-data;boundary=AaB03x.*"
	  }
	},
	"bodyPatterns" : [ {
		"matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"formParameter\\"\\r\\n(Content-Type: .*\\r\\n)?(Content-Transfer-Encoding: .*\\r\\n)?(Content-Length: \\\\d+\\r\\n)?\\r\\n\\".+\\"\\r\\n--\\\\1.*"
  		}, {
    			"matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"someBooleanParameter\\"\\r\\n(Content-Type: .*\\r\\n)?(Content-Transfer-Encoding: .*\\r\\n)?(Content-Length: \\\\d+\\r\\n)?\\r\\n(true|false)\\r\\n--\\\\1.*"
  		}, {
	  "matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"file\\"; filename=\\"[\\\\S\\\\s]+\\"\\r\\n(Content-Type: .*\\r\\n)?(Content-Transfer-Encoding: .*\\r\\n)?(Content-Length: \\\\d+\\r\\n)?\\r\\n[\\\\S\\\\s]+\\r\\n--\\\\1.*"
	} ]
  },
  "response" : {
	"status" : 200,
	"transformers" : [ "response-template", "foo-transformer" ]
  }
}
	'''

93.4 Response

响应必须包含 HTTP 状态代码 ,并且可能包含其他信息。以下代码显示了一个示例:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
		method GET()
		url "/foo"
	}
	response {
		// Status code sent by the server
		// in response to request specified above.
		status OK()
	}
}

YAML.

response:
...
status: 200

除了状态之外,响应还可以包含 headers ,cookies body **,它们的指定方式与请求中的指定方式相同(请参见上一段)。

Tip

通过 Groovy DSL,您可以引用org.springframework.cloud.contract.spec.internal.HttpStatus方法来提供有意义的状态而不是数字。例如。您可以致电OK()来获取状态200BAD_REQUEST()来获取400

93.5 动态属性

Contract 可以包含一些动态属性:时间戳记,ID 等。您不想强迫使用者将自己的时钟设置为总是返回相同的时间值,以使该时间与该存根匹配。

对于 Groovy DSL,您可以通过两种方式在 Contract 中提供动态部分:将它们直接传递到正文中,或在单独的部分bodyMatchers中进行设置。

Note

在 2.0.0 之前,这些版本是使用testMatchersstubMatchers设置的,请查阅migration guide以获取更多信息。

对于 YAML,您只能使用matchers部分。

93.5.1 体内的动态特性

Tip

本节仅对 Groovy DSL 有效。请查看第 93.5.7 节“匹配器节中的动态属性”部分,以获取类似功能的 YAML 示例。

您可以使用value方法设置主体内部的属性,或者如果使用 GroovyMap 符号,则可以使用$()设置主体内部的属性。下面的示例演示如何使用 value 方法设置动态属性:

value(consumer(...), producer(...))
value(c(...), p(...))
value(stub(...), test(...))
value(client(...), server(...))

以下示例显示了如何使用$()设置动态属性:

$(consumer(...), producer(...))
$(c(...), p(...))
$(stub(...), test(...))
$(client(...), server(...))

两种方法都同样有效。 stubclient方法是consumer方法的别名。随后的部分将详细介绍您可以使用这些值做什么。

93.5.2 正则表达式

Tip

本节仅对 Groovy DSL 有效。请查看第 93.5.7 节“匹配器节中的动态属性”部分,以获取类似功能的 YAML 示例。

您可以使用正则表达式在 Contract DSL 中编写您的请求。当您要指示应为遵循给定模式的请求提供给定响应时,这样做特别有用。另外,在测试和服务器端测试都需要使用模式而不是精确值时,可以使用正则表达式。

下面的示例演示如何使用正则表达式编写请求:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method('GET')
		url $(consumer(~/\/[0-9]{2}/), producer('/12'))
	}
	response {
		status OK()
		body(
				id: $(anyNumber()),
				surname: $(
						consumer('Kowalsky'),
						producer(regex('[a-zA-Z]+'))
				),
				name: 'Jan',
				created: $(consumer('2014-02-02 12:23:43'), producer(execute('currentDate(it)'))),
				correlationId: value(consumer('5d1f9fef-e0dc-4f3d-a7e4-72d2220dd827'),
						producer(regex('[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}'))
				)
		)
		headers {
			header 'Content-Type': 'text/plain'
		}
	}
}

您也只能在通信的一侧提供正则表达式。如果这样做,那么 Contract 引擎将自动提供与提供的正则表达式匹配的生成的字符串。以下代码显示了一个示例:

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'PUT'
        url value(consumer(regex('/foo/[0-9]{5}')))
        body([
                requestElement: $(consumer(regex('[0-9]{5}')))
        ])
        headers {
            header('header', $(consumer(regex('application\\/vnd\\.fraud\\.v1\\+json;.*'))))
        }
    }
    response {
        status OK()
        body([
                responseElement: $(producer(regex('[0-9]{7}')))
        ])
        headers {
            contentType("application/vnd.fraud.v1+json")
        }
    }
}

在前面的示例中,通信的另一端具有为请求和响应而生成的相应数据。

Spring Cloud Contract 附带了一系列 sched 义的正则表达式,您可以在 Contract 中使用它们,如以下示例所示:

protected static final Pattern TRUE_OR_FALSE = Pattern.compile(/(true|false)/)
protected static final Pattern ALPHA_NUMERIC = Pattern.compile('[a-zA-Z0-9]+')
protected static final Pattern ONLY_ALPHA_UNICODE = Pattern.compile(/[\p{L}]*/)
protected static final Pattern NUMBER = Pattern.compile('-?(\\d*\\.\\d+|\\d+)')
protected static final Pattern INTEGER = Pattern.compile('-?(\\d+)')
protected static final Pattern POSITIVE_INT = Pattern.compile('([1-9]\\d*)')
protected static final Pattern DOUBLE = Pattern.compile('-?(\\d*\\.\\d+)')
protected static final Pattern HEX = Pattern.compile('[a-fA-F0-9]+')
protected static final Pattern IP_ADDRESS = Pattern.compile('([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])')
protected static final Pattern HOSTNAME_PATTERN = Pattern.compile('((http[s]?|ftp):/)/?([^:/\\s]+)(:[0-9]{1,5})?')
protected static final Pattern EMAIL = Pattern.compile('[a-zA-Z0-9._%+-][emailprotected][a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}')
protected static final Pattern URL = UrlHelper.URL
protected static final Pattern HTTPS_URL = UrlHelper.HTTPS_URL
protected static final Pattern UUID = Pattern.compile('[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}')
protected static final Pattern ANY_DATE = Pattern.compile('(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])')
protected static final Pattern ANY_DATE_TIME = Pattern.compile('([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])')
protected static final Pattern ANY_TIME = Pattern.compile('(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])')
protected static final Pattern NON_EMPTY = Pattern.compile(/[\S\s]+/)
protected static final Pattern NON_BLANK = Pattern.compile(/^\s*\S[\S\s]*/)
protected static final Pattern ISO8601_WITH_OFFSET = Pattern.compile(/([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.\d{3})?(Z|[+-][01]\d:[0-5]\d)/)

protected static Pattern anyOf(String... values){
	return Pattern.compile(values.collect({"^$it\$"}).join("|"))
}

RegexProperty onlyAlphaUnicode() {
	return new RegexProperty(ONLY_ALPHA_UNICODE).asString()
}

RegexProperty alphaNumeric() {
	return new RegexProperty(ALPHA_NUMERIC).asString()
}

RegexProperty number() {
	return new RegexProperty(NUMBER).asDouble()
}

RegexProperty positiveInt() {
	return new RegexProperty(POSITIVE_INT).asInteger()
}

RegexProperty anyBoolean() {
	return new RegexProperty(TRUE_OR_FALSE).asBooleanType()
}

RegexProperty anInteger() {
	return new RegexProperty(INTEGER).asInteger()
}

RegexProperty aDouble() {
	return new RegexProperty(DOUBLE).asDouble()
}

RegexProperty ipAddress() {
	return new RegexProperty(IP_ADDRESS).asString()
}

RegexProperty hostname() {
	return new RegexProperty(HOSTNAME_PATTERN).asString()
}

RegexProperty email() {
	return new RegexProperty(EMAIL).asString()
}

RegexProperty url() {
	return new RegexProperty(URL).asString()
}

RegexProperty httpsUrl() {
	return new RegexProperty(HTTPS_URL).asString()
}

RegexProperty uuid(){
	return new RegexProperty(UUID).asString()
}

RegexProperty isoDate() {
	return new RegexProperty(ANY_DATE).asString()
}

RegexProperty isoDateTime() {
	return new RegexProperty(ANY_DATE_TIME).asString()
}

RegexProperty isoTime() {
	return new RegexProperty(ANY_TIME).asString()
}

RegexProperty iso8601WithOffset() {
	return new RegexProperty(ISO8601_WITH_OFFSET).asString()
}

RegexProperty nonEmpty() {
	return new RegexProperty(NON_EMPTY).asString()
}

RegexProperty nonBlank() {
	return new RegexProperty(NON_BLANK).asString()
}

在您的 Contract 中,可以按以下示例所示使用它:

Contract dslWithOptionalsInString = Contract.make {
    priority 1
    request {
        method POST()
        url '/users/password'
        headers {
            contentType(applicationJson())
        }
        body(
                email: $(consumer(optional(regex(email()))), producer('[emailprotected]')),
                callback_url: $(consumer(regex(hostname())), producer('http://partners.com'))
        )
    }
    response {
        status 404
        headers {
            contentType(applicationJson())
        }
        body(
                code: value(consumer("123123"), producer(optional("123123"))),
                message: "User not found by email = [${value(producer(regex(email())), consumer('[emailprotected]'))}]"
        )
    }
}

为了使事情变得更加简单,您可以使用一组 sched 义的对象,这些对象将自动假定您要传递正则表达式。所有这些方法均以any前缀开头:

T anyAlphaUnicode()

T anyAlphaNumeric()

T anyNumber()

T anyInteger()

T anyPositiveInt()

T anyDouble()

T anyHex()

T aBoolean()

T anyIpAddress()

T anyHostname()

T anyEmail()

T anyUrl()

T anyHttpsUrl()

T anyUuid()

T anyDate()

T anyDateTime()

T anyTime()

T anyIso8601WithOffset()

T anyNonBlankString()

T anyNonEmptyString()

T anyOf(String... values)

这是如何引用这些方法的示例:

Contract contractDsl = Contract.make {
	label 'trigger_event'
	input {
		triggeredBy('toString()')
	}
	outputMessage {
		sentTo 'topic.rateablequote'
		body([
				alpha: $(anyAlphaUnicode()),
				number: $(anyNumber()),
				anInteger: $(anyInteger()),
				positiveInt: $(anyPositiveInt()),
				aDouble: $(anyDouble()),
				aBoolean: $(aBoolean()),
				ip: $(anyIpAddress()),
				hostname: $(anyHostname()),
				email: $(anyEmail()),
				url: $(anyUrl()),
				httpsUrl: $(anyHttpsUrl()),
				uuid: $(anyUuid()),
				date: $(anyDate()),
				dateTime: $(anyDateTime()),
				time: $(anyTime()),
				iso8601WithOffset: $(anyIso8601WithOffset()),
				nonBlankString: $(anyNonBlankString()),
				nonEmptyString: $(anyNonEmptyString()),
				anyOf: $(anyOf('foo', 'bar'))
		])
	}
}

93.5.3 传递可选参数

Tip

本节仅对 Groovy DSL 有效。请查看第 93.5.7 节“匹配器节中的动态属性”部分,以获取类似功能的 YAML 示例。

可以在 Contract 中提供可选参数。但是,只能为以下项提供可选参数:

  • 请求的* STUB *面

  • 响应的“测试”部分

以下示例显示如何提供可选参数:

org.springframework.cloud.contract.spec.Contract.make {
	priority 1
	request {
		method 'POST'
		url '/users/password'
		headers {
			contentType(applicationJson())
		}
		body(
				email: $(consumer(optional(regex(email()))), producer('[emailprotected]')),
				callback_url: $(consumer(regex(hostname())), producer('http://partners.com'))
		)
	}
	response {
		status 404
		headers {
			header 'Content-Type': 'application/json'
		}
		body(
				code: value(consumer("123123"), producer(optional("123123")))
		)
	}
}

通过使用optional()方法包装正文的一部分,可以创建必须存在 0 次或多次的正则表达式。

如果将 Spock 用于以下内容,则将从上一个示例生成以下测试:

"""
 given:
  def request = given()
    .header("Content-Type", "application/json")
    .body('''{"email":"[emailprotected]","callback_url":"http://partners.com"}''')

 when:
  def response = given().spec(request)
    .post("/users/password")

 then:
  response.statusCode == 404
  response.header('Content-Type')  == 'application/json'
 and:
  DocumentContext parsedJson = JsonPath.parse(response.body.asString())
  assertThatJson(parsedJson).field("['code']").matches("(123123)?")
"""

还将生成以下存根:

'''
{
  "request" : {
	"url" : "/users/password",
	"method" : "POST",
	"bodyPatterns" : [ {
	  "matchesJsonPath" : "$[?(@.['email'] =~ /([a-zA-Z0-9._%+-][emailprotected][a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,6})?/)]"
	}, {
	  "matchesJsonPath" : "$[?(@.['callback_url'] =~ /((http[s]?|ftp):\\\\/)\\\\/?([^:\\\\/\\\\s]+)(:[0-9]{1,5})?/)]"
	} ],
	"headers" : {
	  "Content-Type" : {
		"equalTo" : "application/json"
	  }
	}
  },
  "response" : {
	"status" : 404,
	"body" : "{\\"code\\":\\"123123\\",\\"message\\":\\"User not found by email == [[email protected]]\\"}",
	"headers" : {
	  "Content-Type" : "application/json"
	}
  },
  "priority" : 1
}
'''

93.5.4 在服务器端执行自定义方法

Tip

本节仅对 Groovy DSL 有效。请查看第 93.5.7 节“匹配器节中的动态属性”部分,以获取类似功能的 YAML 示例。

您可以定义在测试期间在服务器端执行的方法调用。可以将这种方法添加到配置中定义为“ baseClassForTests”的类中。以下代码显示了测试案例的 Contract 部分的示例:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'PUT'
		url $(consumer(regex('^/api/[0-9]{2}$')), producer('/api/12'))
		headers {
			header 'Content-Type': 'application/json'
		}
		body '''\
				[{
					"text": "Gonna see you at Warsaw"
				}]
			'''
	}
	response {
		body (
				path: $(consumer('/api/12'), producer(regex('^/api/[0-9]{2}$'))),
				correlationId: $(consumer('1223456'), producer(execute('isProperCorrelationId($it)')))
		)
		status OK()
	}
}

以下代码显示了测试用例的 Base Class 部分:

abstract class BaseMockMvcSpec extends Specification {

	def setup() {
		RestAssuredMockMvc.standaloneSetup(new PairIdController())
	}

	void isProperCorrelationId(Integer correlationId) {
		assert correlationId == 123456
	}

	void isEmpty(String value) {
		assert value == null
	}

}

Tip

您不能同时使用 String 和execute来执行串联。例如,调用header('Authorization', 'Bearer ' + execute('authToken()'))会导致不正确的结果。而是调用header('Authorization', execute('authToken()'))并确保authToken()方法返回您需要的所有内容。

从 JSON 读取的对象的类型可以是以下之一,具体取决于 JSON 路径:

  • String:如果您指向 JSON 中的String值。

  • JSONArray:如果您指向 JSON 中的List

  • Map:如果您指向 JSON 中的Map

  • Number:如果您指向 JSON 中的IntegerDouble等。

  • Boolean:如果您指向 JSON 中的Boolean

在 Contract 的请求部分,您可以指定body应该从方法中获取。

Tip

您必须同时提供 Consumer 和生产者双方。 execute部分适用于整个身体-不适用于身体的一部分。

以下示例显示如何从 JSON 读取对象:

Contract contractDsl = Contract.make {
	request {
		method 'GET'
		url '/something'
		body(
				$(c('foo'), p(execute('hashCode()')))
		)
	}
	response {
		status OK()
	}
}

前面的示例导致在请求正文中调用hashCode()方法。它应类似于以下代码:

// given:
 MockMvcRequestSpecification request = given()
   .body(hashCode());

// when:
 ResponseOptions response = given().spec(request)
   .get("/something");

// then:
 assertThat(response.statusCode()).isEqualTo(200);

93.5.5 从响应中引用请求

最好的情况是提供固定值,但是有时您需要在响应中引用一个请求。

如果使用 Groovy DSL 编写 Contract,则可以使用fromRequest()方法,该方法使您可以从 HTTP 请求中引用一堆元素。您可以使用以下选项:

  • fromRequest().url():返回请求 URL 和查询参数。

  • fromRequest().query(String key):返回具有给定名称的第一个查询参数。

  • fromRequest().query(String key, int index):返回具有给定名称的第 n 个查询参数。

  • fromRequest().path():返回完整路径。

  • fromRequest().path(int index):返回第 n 个路径元素。

  • fromRequest().header(String key):返回具有给定名称的第一个 Headers。

  • fromRequest().header(String key, int index):返回具有给定名称的第 n 个标题。

  • fromRequest().body():返回完整的请求正文。

  • fromRequest().body(String jsonPath):从请求中返回与 JSON 路径匹配的元素。

如果您使用的是 YAMLContract 定义,则必须使用带有自定义Handlebars {{{ }}}表示法的 Spring Cloud Contract 函数来实现此目的。

  • {{{ request.url }}}:返回请求 URL 和查询参数。

  • {{{ request.query.key.[index] }}}:返回具有给定名称的第 n 个查询参数。例如。对于键foo,第一项{{{ request.query.foo.[0] }}}

  • {{{ request.path }}}:返回完整路径。

  • {{{ request.path.[index] }}}:返回第 n 个路径元素。例如。对于第一次 Importing`````\ {{{ request.path.[0] }}}

  • {{{ request.headers.key }}}:返回具有给定名称的第一个 Headers。

  • {{{ request.headers.key.[index] }}}:返回具有给定名称的第 n 个标题。

  • {{{ request.body }}}:返回完整的请求正文。

  • {{{ jsonpath this 'your.json.path' }}}:从请求中返回与 JSON 路径匹配的元素。例如。用于 json 路径$.foo-{{{ jsonpath this '$.foo' }}}

考虑以下 Contract:

Groovy DSL.


YAML.

request:
  method: GET
  url: /api/v1/xxxx
  queryParameters:
    foo:
      - bar
      - bar2
  headers:
    Authorization:
      - secret
      - secret2
  body:
    foo: bar
    baz: 5
response:
  status: 200
  headers:
    Authorization: "foo {{{ request.headers.Authorization.0 }}} bar"
  body:
    url: "{{{ request.url }}}"
    path: "{{{ request.path }}}"
    pathIndex: "{{{ request.path.1 }}}"
    param: "{{{ request.query.foo }}}"
    paramIndex: "{{{ request.query.foo.1 }}}"
    authorization: "{{{ request.headers.Authorization.0 }}}"
    authorization2: "{{{ request.headers.Authorization.1 }}"
    fullBody: "{{{ request.body }}}"
    responseFoo: "{{{ jsonpath this '$.foo' }}}"
    responseBaz: "{{{ jsonpath this '$.baz' }}}"
    responseBaz2: "Bla bla {{{ jsonpath this '$.foo' }}} bla bla"

运行 JUnit 测试生成将导致类似于以下示例的测试:

// given:
 MockMvcRequestSpecification request = given()
   .header("Authorization", "secret")
   .header("Authorization", "secret2")
   .body("{\"foo\":\"bar\",\"baz\":5}");

// when:
 ResponseOptions response = given().spec(request)
   .queryParam("foo","bar")
   .queryParam("foo","bar2")
   .get("/api/v1/xxxx");

// then:
 assertThat(response.statusCode()).isEqualTo(200);
 assertThat(response.header("Authorization")).isEqualTo("foo secret bar");
// and:
 DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
 assertThatJson(parsedJson).field("['fullBody']").isEqualTo("{\"foo\":\"bar\",\"baz\":5}");
 assertThatJson(parsedJson).field("['authorization']").isEqualTo("secret");
 assertThatJson(parsedJson).field("['authorization2']").isEqualTo("secret2");
 assertThatJson(parsedJson).field("['path']").isEqualTo("/api/v1/xxxx");
 assertThatJson(parsedJson).field("['param']").isEqualTo("bar");
 assertThatJson(parsedJson).field("['paramIndex']").isEqualTo("bar2");
 assertThatJson(parsedJson).field("['pathIndex']").isEqualTo("v1");
 assertThatJson(parsedJson).field("['responseBaz']").isEqualTo(5);
 assertThatJson(parsedJson).field("['responseFoo']").isEqualTo("bar");
 assertThatJson(parsedJson).field("['url']").isEqualTo("/api/v1/xxxx?foo=bar&foo=bar2");
 assertThatJson(parsedJson).field("['responseBaz2']").isEqualTo("Bla bla bar bla bla");

如您所见,响应中已正确引用了请求中的元素。

生成的 WireMock 存根应类似于以下示例:

{
  "request" : {
    "urlPath" : "/api/v1/xxxx",
    "method" : "POST",
    "headers" : {
      "Authorization" : {
        "equalTo" : "secret2"
      }
    },
    "queryParameters" : {
      "foo" : {
        "equalTo" : "bar2"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$[?(@.['baz'] == 5)]"
    }, {
      "matchesJsonPath" : "$[?(@.['foo'] == 'bar')]"
    } ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\"authorization\":\"{{{request.headers.Authorization.[0]}}}\",\"path\":\"{{{request.path}}}\",\"responseBaz\":{{{jsonpath this '$.baz'}}} ,\"param\":\"{{{request.query.foo.[0]}}}\",\"pathIndex\":\"{{{request.path.[1]}}}\",\"responseBaz2\":\"Bla bla {{{jsonpath this '$.foo'}}} bla bla\",\"responseFoo\":\"{{{jsonpath this '$.foo'}}}\",\"authorization2\":\"{{{request.headers.Authorization.[1]}}}\",\"fullBody\":\"{{{escapejsonbody}}}\",\"url\":\"{{{request.url}}}\",\"paramIndex\":\"{{{request.query.foo.[1]}}}\"}",
    "headers" : {
      "Authorization" : "{{{request.headers.Authorization.[0]}}};foo"
    },
    "transformers" : [ "response-template" ]
  }
}

发送请求(例如 Contract 的request部分中提出的请求)会导致发送以下响应正文:

{
  "url" : "/api/v1/xxxx?foo=bar&foo=bar2",
  "path" : "/api/v1/xxxx",
  "pathIndex" : "v1",
  "param" : "bar",
  "paramIndex" : "bar2",
  "authorization" : "secret",
  "authorization2" : "secret2",
  "fullBody" : "{\"foo\":\"bar\",\"baz\":5}",
  "responseFoo" : "bar",
  "responseBaz" : 5,
  "responseBaz2" : "Bla bla bar bla bla"
}

Tip

此功能仅适用于版本大于或等于 2.5.1 的 WireMock。 Spring Cloud Contract Verifier 使用 WireMock 的response-template响应转换器。它使用把手将“ Mustache {{{ }}}”模板转换为适当的值。此外,它注册了两个帮助程序功能:

  • escapejsonbody:以可嵌入 JSON 的格式转义请求正文。

  • jsonpath:对于给定的参数,在请求正文中找到一个对象。

93.5.6 注册您自己的 WireMock 扩展

WireMock 允许您注册自定义扩展。默认情况下,Spring Cloud Contract 注册该转换器,使您可以引用响应中的请求。如果要提供自己的 extensions,则可以注册org.springframework.cloud.contract.verifier.dsl.wiremock.WireMockExtensions接口的实现。由于我们使用 spring.factories 扩展方法,因此您可以在META-INF/spring.factories文件中创建一个类似于以下内容的条目:

org.springframework.cloud.contract.verifier.dsl.wiremock.WireMockExtensions=\
org.springframework.cloud.contract.stubrunner.provider.wiremock.TestWireMockExtensions
org.springframework.cloud.contract.spec.ContractConverter=\
org.springframework.cloud.contract.stubrunner.TestCustomYamlContractConverter

以下是自定义扩展的示例:

TestWireMockExtensions.groovy.

package org.springframework.cloud.contract.verifier.dsl.wiremock

import com.github.tomakehurst.wiremock.extension.Extension

/**
 * Extension that registers the default transformer and the custom one
 */
class TestWireMockExtensions implements WireMockExtensions {
	@Override
	List<Extension> extensions() {
		return [
				new DefaultResponseTransformer(),
				new CustomExtension()
		]
	}
}

class CustomExtension implements Extension {

	@Override
	String getName() {
		return "foo-transformer"
	}
}

Tip

如果要将转换仅应用于明确需要它的 Map,请记住重写applyGlobally()方法并将其设置为false

93.5.7“匹配器”部分中的动态属性

如果您使用Pact,则以下讨论可能看起来很熟悉。很少有用户习惯于在主体之间进行分隔并设置 Contract 的动态部分。

您可以使用bodyMatchers部分有两个原因:

  • 定义应该以存根结尾的动态值。您可以在 Contract 的requestinputMessage部分进行设置。

  • 验证测试结果。此部分位于 Contract 的responseoutputMessage一侧。

当前,Spring Cloud Contract Verifier 仅支持具有以下匹配可能性的基于 JSON 路径的匹配器:

Groovy DSL

  • 对于存根(在 Consumer 方面的测试中):

  • byEquality():通过提供的 JSON 路径从 Consumer 的请求中获取的值必须等于 Contract 中提供的值。

    • byRegex(…):通过提供的 JSON 路径从 Consumer 请求中获取的值必须与正则表达式匹配。您还可以传递期望的匹配值的类型(例如asString()asLong()等)

    • byDate():通过提供的 JSON 路径从 Consumer 请求中获取的值必须与 ISO 日期值的正则表达式匹配。

    • byTimestamp():通过提供的 JSON 路径从 Consumer 请求中获取的值必须与 ISO DateTime 值的正则表达式匹配。

    • byTime():通过提供的 JSON 路径从 Consumer 请求中获取的值必须与 ISO 时间值的正则表达式匹配。

  • 进行验证(在生产者方生成的测试中):

  • byEquality():通过提供的 JSON 路径从生产者的响应中获取的值必须等于 Contract 中提供的值。

    • byRegex(…):通过提供的 JSON 路径从生产者的响应中获取的值必须与正则表达式匹配。

    • byDate():通过提供的 JSON 路径从生产者的响应中获取的值必须与 ISO 日期值的正则表达式匹配。

    • byTimestamp():通过提供的 JSON 路径从生产者的响应中获取的值必须与 ISO DateTime 值的正则表达式匹配。

    • byTime():通过提供的 JSON 路径从生产者的响应中获取的值必须与 ISO 时间值的正则表达式匹配。

    • byType():通过提供的 JSON 路径从生产者的响应中获取的值必须与 Contract 中的响应主体中定义的类型相同。 byType可以关闭,您可以在其中设置minOccurrencemaxOccurrence。对于请求端,应使用闭包声明集合的大小。这样,您可以声明展平集合的大小。要检查未展平的集合的大小,请对byCommand(…) testMatcher 使用自定义方法。

    • byCommand(…):通过提供的 JSON 路径从生产者的响应中获取的值作为 Importing 传递到您提供的自定义方法。例如,byCommand('foo($it)')导致调用foo方法,该方法将与 JSON Path 匹配的值传递给该方法。从 JSON 读取的对象的类型可以是以下之一,具体取决于 JSON 路径:

  • String:如果指向String值。

    • JSONArray:如果您指向List

    • Map:如果您指向Map

    • Number:如果您指向IntegerDouble或其他类型的数字。

    • Boolean:如果您指向Boolean

    • byNull():通过提供的 JSON 路径从响应中获取的值必须为 null

YAML. 请阅读 Groovy 部分,以详细了解类型的含义

对于 YAML,匹配器的结构如下所示

- path: $.foo
  type: by_regex
  value: bar
  regexType: as_string

或者,如果您要使用 sched 义的正则表达式[only_alpha_unicode, number, any_boolean, ip_address, hostname, email, url, uuid, iso_date, iso_date_time, iso_time, iso_8601_with_offset, non_empty, non_blank]之一:

- path: $.foo
  type: by_regex
  predefined: only_alpha_unicode

您可以在下面找到允许的“类型”列表。

  • 对于stubMatchers

  • by_equality

    • by_regex

    • by_date

    • by_timestamp

    • by_time

    • by_type

  • 还有 2 个其他字段:minOccurrencemaxOccurrence

  • 对于testMatchers

  • by_equality

    • by_regex

    • by_date

    • by_timestamp

    • by_time

    • by_type

  • 还有 2 个其他字段:minOccurrencemaxOccurrence

    • by_command

    • by_null

您还可以通过regexType字段定义正则表达式对应的类型。您可以在下面找到允许的正则表达式类型列表:

  • as_integer

  • as_double

  • as_float,

  • as_long

  • as_short

  • as_boolean

  • as_string

考虑以下示例:

Groovy DSL.

Contract contractDsl = Contract.make {
	request {
		method 'GET'
		urlPath '/get'
		body([
				duck                : 123,
				alpha               : 'abc',
				number              : 123,
				aBoolean            : true,
				date                : '2017-01-01',
				dateTime            : '2017-01-01T01:23:45',
				time                : '01:02:34',
				valueWithoutAMatcher: 'foo',
				valueWithTypeMatch  : 'string',
				key                 : [
						'complex.key': 'foo'
				]
		])
		bodyMatchers {
			jsonPath('$.duck', byRegex("[0-9]{3}").asInteger())
			jsonPath('$.duck', byEquality())
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()).asString())
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()).asInteger())
			jsonPath('$.aBoolean', byRegex(anyBoolean()).asBooleanType())
			jsonPath('$.date', byDate())
			jsonPath('$.dateTime', byTimestamp())
			jsonPath('$.time', byTime())
			jsonPath("\$.['key'].['complex.key']", byEquality())
		}
		headers {
			contentType(applicationJson())
		}
	}
	response {
		status OK()
		body([
				duck                 : 123,
				alpha                : 'abc',
				number               : 123,
				positiveInteger      : 1234567890,
				negativeInteger      : -1234567890,
				positiveDecimalNumber: 123.4567890,
				negativeDecimalNumber: -123.4567890,
				aBoolean             : true,
				date                 : '2017-01-01',
				dateTime             : '2017-01-01T01:23:45',
				time                 : "01:02:34",
				valueWithoutAMatcher : 'foo',
				valueWithTypeMatch   : 'string',
				valueWithMin         : [
						1, 2, 3
				],
				valueWithMax         : [
						1, 2, 3
				],
				valueWithMinMax      : [
						1, 2, 3
				],
				valueWithMinEmpty    : [],
				valueWithMaxEmpty    : [],
				key                  : [
						'complex.key': 'foo'
				],
				nullValue            : null
		])
		bodyMatchers {
			// asserts the jsonpath value against manual regex
			jsonPath('$.duck', byRegex("[0-9]{3}").asInteger())
			// asserts the jsonpath value against the provided value
			jsonPath('$.duck', byEquality())
			// asserts the jsonpath value against some default regex
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()).asString())
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()).asInteger())
			jsonPath('$.positiveInteger', byRegex(anInteger()).asInteger())
			jsonPath('$.negativeInteger', byRegex(anInteger()).asInteger())
			jsonPath('$.positiveDecimalNumber', byRegex(aDouble()).asDouble())
			jsonPath('$.negativeDecimalNumber', byRegex(aDouble()).asDouble())
			jsonPath('$.aBoolean', byRegex(anyBoolean()).asBooleanType())
			// asserts vs inbuilt time related regex
			jsonPath('$.date', byDate())
			jsonPath('$.dateTime', byTimestamp())
			jsonPath('$.time', byTime())
			// asserts that the resulting type is the same as in response body
			jsonPath('$.valueWithTypeMatch', byType())
			jsonPath('$.valueWithMin', byType {
				// results in verification of size of array (min 1)
				minOccurrence(1)
			})
			jsonPath('$.valueWithMax', byType {
				// results in verification of size of array (max 3)
				maxOccurrence(3)
			})
			jsonPath('$.valueWithMinMax', byType {
				// results in verification of size of array (min 1 & max 3)
				minOccurrence(1)
				maxOccurrence(3)
			})
			jsonPath('$.valueWithMinEmpty', byType {
				// results in verification of size of array (min 0)
				minOccurrence(0)
			})
			jsonPath('$.valueWithMaxEmpty', byType {
				// results in verification of size of array (max 0)
				maxOccurrence(0)
			})
			// will execute a method `assertThatValueIsANumber`
			jsonPath('$.duck', byCommand('assertThatValueIsANumber($it)'))
			jsonPath("\$.['key'].['complex.key']", byEquality())
			jsonPath('$.nullValue', byNull())
		}
		headers {
			contentType(applicationJson())
			header('Some-Header', $(c('someValue'), p(regex('[a-zA-Z]{9}'))))
		}
	}
}

YAML.

request:
  method: GET
  urlPath: /get/1
  headers:
    Content-Type: application/json
  cookies:
    foo: 2
    bar: 3
  queryParameters:
    limit: 10
    offset: 20
    filter: 'email'
    sort: name
    search: 55
    age: 99
    name: John.Doe
    email: '[emailprotected]'
  body:
    duck: 123
    alpha: "abc"
    number: 123
    aBoolean: true
    date: "2017-01-01"
    dateTime: "2017-01-01T01:23:45"
    time: "01:02:34"
    valueWithoutAMatcher: "foo"
    valueWithTypeMatch: "string"
    key:
      "complex.key": 'foo'
    nullValue: null
    valueWithMin:
      - 1
      - 2
      - 3
    valueWithMax:
      - 1
      - 2
      - 3
    valueWithMinMax:
      - 1
      - 2
      - 3
    valueWithMinEmpty: []
    valueWithMaxEmpty: []
  matchers:
    url:
      regex: /get/[0-9]
      # predefined:
      # execute a method
      #command: 'equals($it)'
    queryParameters:
      - key: limit
        type: equal_to
        value: 20
      - key: offset
        type: containing
        value: 20
      - key: sort
        type: equal_to
        value: name
      - key: search
        type: not_matching
        value: '^[0-9]{2}$'
      - key: age
        type: not_matching
        value: '^\\w*$'
      - key: name
        type: matching
        value: 'John.*'
      - key: hello
        type: absent
    cookies:
      - key: foo
        regex: '[0-9]'
      - key: bar
        command: 'equals($it)'
    headers:
      - key: Content-Type
        regex: "application/json.*"
    body:
      - path: $.duck
        type: by_regex
        value: "[0-9]{3}"
      - path: $.duck
        type: by_equality
      - path: $.alpha
        type: by_regex
        predefined: only_alpha_unicode
      - path: $.alpha
        type: by_equality
      - path: $.number
        type: by_regex
        predefined: number
      - path: $.aBoolean
        type: by_regex
        predefined: any_boolean
      - path: $.date
        type: by_date
      - path: $.dateTime
        type: by_timestamp
      - path: $.time
        type: by_time
      - path: "$.['key'].['complex.key']"
        type: by_equality
      - path: $.nullvalue
        type: by_null
      - path: $.valueWithMin
        type: by_type
        minOccurrence: 1
      - path: $.valueWithMax
        type: by_type
        maxOccurrence: 3
      - path: $.valueWithMinMax
        type: by_type
        minOccurrence: 1
        maxOccurrence: 3
response:
  status: 200
  cookies:
    foo: 1
    bar: 2
  body:
    duck: 123
    alpha: "abc"
    number: 123
    aBoolean: true
    date: "2017-01-01"
    dateTime: "2017-01-01T01:23:45"
    time: "01:02:34"
    valueWithoutAMatcher: "foo"
    valueWithTypeMatch: "string"
    valueWithMin:
      - 1
      - 2
      - 3
    valueWithMax:
      - 1
      - 2
      - 3
    valueWithMinMax:
      - 1
      - 2
      - 3
    valueWithMinEmpty: []
    valueWithMaxEmpty: []
    key:
      'complex.key' : 'foo'
    nulValue: null
  matchers:
    headers:
      - key: Content-Type
        regex: "application/json.*"
    cookies:
      - key: foo
        regex: '[0-9]'
      - key: bar
        command: 'equals($it)'
    body:
      - path: $.duck
        type: by_regex
        value: "[0-9]{3}"
      - path: $.duck
        type: by_equality
      - path: $.alpha
        type: by_regex
        predefined: only_alpha_unicode
      - path: $.alpha
        type: by_equality
      - path: $.number
        type: by_regex
        predefined: number
      - path: $.aBoolean
        type: by_regex
        predefined: any_boolean
      - path: $.date
        type: by_date
      - path: $.dateTime
        type: by_timestamp
      - path: $.time
        type: by_time
      - path: $.valueWithTypeMatch
        type: by_type
      - path: $.valueWithMin
        type: by_type
        minOccurrence: 1
      - path: $.valueWithMax
        type: by_type
        maxOccurrence: 3
      - path: $.valueWithMinMax
        type: by_type
        minOccurrence: 1
        maxOccurrence: 3
      - path: $.valueWithMinEmpty
        type: by_type
        minOccurrence: 0
      - path: $.valueWithMaxEmpty
        type: by_type
        maxOccurrence: 0
      - path: $.duck
        type: by_command
        value: assertThatValueIsANumber($it)
      - path: $.nullValue
        type: by_null
        value: null
  headers:
    Content-Type: application/json

在前面的示例中,您可以在matchers部分中查看 Contract 的动态部分。对于请求部分,您可以看到,对于valueWithoutAMatcher以外的所有字段,存根都应包含的正则表达式的值已明确设置。对于valueWithoutAMatcher,验证的方式与不使用匹配器的方式相同。在这种情况下,测试将执行相等性检查。

对于bodyMatchers部分中的响应端,我们以类似的方式定义动态部分。唯一的区别是还存在byType个匹配器。验证程序引擎检查四个字段,以验证来自测试的响应是否具有与 JSON 路径匹配给定字段的值,与响应主体中定义的类型相同的类型,并通过以下检查(基于方法被调用):

  • 对于$.valueWithTypeMatch,引擎检查类型是否相同。

  • 对于$.valueWithMin,引擎检查类型并 assert 该大小是否大于或等于最小出现次数。

  • 对于$.valueWithMax,引擎检查类型并 assert 大小是否小于或等于最大出现次数。

  • 对于$.valueWithMinMax,引擎检查类型并 assert 大小是否在最小和最大发生之间。

生成的测试类似于以下示例(请注意and部分将自动生成的 assert 和匹配项的 assert 分隔开):

// given:
 MockMvcRequestSpecification request = given()
   .header("Content-Type", "application/json")
   .body("{\"duck\":123,\"alpha\":\"abc\",\"number\":123,\"aBoolean\":true,\"date\":\"2017-01-01\",\"dateTime\":\"2017-01-01T01:23:45\",\"time\":\"01:02:34\",\"valueWithoutAMatcher\":\"foo\",\"valueWithTypeMatch\":\"string\",\"key\":{\"complex.key\":\"foo\"}}");

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

// 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("['valueWithoutAMatcher']").isEqualTo("foo");
// and:
 assertThat(parsedJson.read("$.duck", String.class)).matches("[0-9]{3}");
 assertThat(parsedJson.read("$.duck", Integer.class)).isEqualTo(123);
 assertThat(parsedJson.read("$.alpha", String.class)).matches("[\\p{L}]*");
 assertThat(parsedJson.read("$.alpha", String.class)).isEqualTo("abc");
 assertThat(parsedJson.read("$.number", String.class)).matches("-?(\\d*\\.\\d+|\\d+)");
 assertThat(parsedJson.read("$.aBoolean", String.class)).matches("(true|false)");
 assertThat(parsedJson.read("$.date", String.class)).matches("(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])");
 assertThat(parsedJson.read("$.dateTime", String.class)).matches("([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
 assertThat(parsedJson.read("$.time", String.class)).matches("(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
 assertThat((Object) parsedJson.read("$.valueWithTypeMatch")).isInstanceOf(java.lang.String.class);
 assertThat((Object) parsedJson.read("$.valueWithMin")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMin", java.util.Collection.class)).as("$.valueWithMin").hasSizeGreaterThanOrEqualTo(1);
 assertThat((Object) parsedJson.read("$.valueWithMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMax", java.util.Collection.class)).as("$.valueWithMax").hasSizeLessThanOrEqualTo(3);
 assertThat((Object) parsedJson.read("$.valueWithMinMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinMax", java.util.Collection.class)).as("$.valueWithMinMax").hasSizeBetween(1, 3);
 assertThat((Object) parsedJson.read("$.valueWithMinEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinEmpty", java.util.Collection.class)).as("$.valueWithMinEmpty").hasSizeGreaterThanOrEqualTo(0);
 assertThat((Object) parsedJson.read("$.valueWithMaxEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMaxEmpty", java.util.Collection.class)).as("$.valueWithMaxEmpty").hasSizeLessThanOrEqualTo(0);
 assertThatValueIsANumber(parsedJson.read("$.duck"));
 assertThat(parsedJson.read("$.['key'].['complex.key']", String.class)).isEqualTo("foo");

Tip

注意,对于byCommand方法,该示例调用assertThatValueIsANumber。此方法必须在测试 Base Class 中定义或静态导入到测试中。请注意,byCommand调用已转换为assertThatValueIsANumber(parsedJson.read("$.duck"));。这意味着引擎采用了方法名称,并将正确的 JSON 路径作为参数传递给它。

在下面的示例中,生成的 WireMock 存根(stub)是:

'''
{
  "request" : {
    "urlPath" : "/get",
    "method" : "POST",
    "headers" : {
      "Content-Type" : {
        "matches" : "application/json.*"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$.['list'].['some'].['nested'][?(@.['anothervalue'] == 4)]"
    }, {
      "matchesJsonPath" : "$[?(@.['valueWithoutAMatcher'] == 'foo')]"
    }, {
      "matchesJsonPath" : "$[?(@.['valueWithTypeMatch'] == 'string')]"
    }, {
      "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['json'] == 'with value')]"
    }, {
      "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['anothervalue'] == 4)]"
    }, {
      "matchesJsonPath" : "$[?(@.duck =~ /([0-9]{3})/)]"
    }, {
      "matchesJsonPath" : "$[?(@.duck == 123)]"
    }, {
      "matchesJsonPath" : "$[?(@.alpha =~ /([\\\\p{L}]*)/)]"
    }, {
      "matchesJsonPath" : "$[?(@.alpha == 'abc')]"
    }, {
      "matchesJsonPath" : "$[?(@.number =~ /(-?(\\\\d*\\\\.\\\\d+|\\\\d+))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.aBoolean =~ /((true|false))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.date =~ /((\\\\d\\\\d\\\\d\\\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.dateTime =~ /(([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.time =~ /((2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
    }, {
      "matchesJsonPath" : "$.list.some.nested[?(@.json =~ /(.*)/)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMin.size() >= 1)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMax.size() <= 3)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMinMax.size() >= 1 && @.valueWithMinMax.size() <= 3)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithOccurrence.size() >= 4 && @.valueWithOccurrence.size() <= 4)]"
    } ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\\"date\\":\\"2017-01-01\\",\\"dateTime\\":\\"2017-01-01T01:23:45\\",\\"aBoolean\\":true,\\"valueWithMax\\":[1,2,3],\\"valueWithOccurrence\\":[1,2,3,4],\\"number\\":123,\\"duck\\":123,\\"alpha\\":\\"abc\\",\\"valueWithMin\\":[1,2,3],\\"time\\":\\"01:02:34\\",\\"valueWithTypeMatch\\":\\"string\\",\\"valueWithMinMax\\":[1,2,3],\\"valueWithoutAMatcher\\":\\"foo\\"}",
    "headers" : {
      "Content-Type" : "application/json"
    },
    "transformers" : [ "response-template" ]
  }
}
'''

Tip

如果使用matcher,则从 JSONassert 中删除带有 JSON 路径的matcher地址的请求和响应部分。在验证集合的情况下,您必须为集合的所有元素创建匹配器。

考虑以下示例:

Contract.make {
    request {
        method 'GET'
        url("/foo")
    }
    response {
        status OK()
        body(events: [[
                                 operation          : 'EXPORT',
                                 eventId            : '16f1ed75-0bcc-4f0d-a04d-3121798faf99',
                                 status             : 'OK'
                         ], [
                                 operation          : 'INPUT_PROCESSING',
                                 eventId            : '3bb4ac82-6652-462f-b6d1-75e424a0024a',
                                 status             : 'OK'
                         ]
                ]
        )
        bodyMatchers {
            jsonPath('$.events[0].operation', byRegex('.+'))
            jsonPath('$.events[0].eventId', byRegex('^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$'))
            jsonPath('$.events[0].status', byRegex('.+'))
        }
    }
}

前面的代码导致创建以下测试(代码块仅显示 assert 部分):

and:
	DocumentContext parsedJson = JsonPath.parse(response.body.asString())
	assertThatJson(parsedJson).array("['events']").contains("['eventId']").isEqualTo("16f1ed75-0bcc-4f0d-a04d-3121798faf99")
	assertThatJson(parsedJson).array("['events']").contains("['operation']").isEqualTo("EXPORT")
	assertThatJson(parsedJson).array("['events']").contains("['operation']").isEqualTo("INPUT_PROCESSING")
	assertThatJson(parsedJson).array("['events']").contains("['eventId']").isEqualTo("3bb4ac82-6652-462f-b6d1-75e424a0024a")
	assertThatJson(parsedJson).array("['events']").contains("['status']").isEqualTo("OK")
and:
	assertThat(parsedJson.read("\$.events[0].operation", String.class)).matches(".+")
	assertThat(parsedJson.read("\$.events[0].eventId", String.class)).matches("^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\$")
	assertThat(parsedJson.read("\$.events[0].status", String.class)).matches(".+")

如您所见,assert 的格式不正确。仅声明数组的第一个元素。为了解决此问题,应将 assert 应用于整个$.events集合,并使用byCommand(…)方法 assert。

93.6 JAX-RS 支持

Spring Cloud Contract Verifier 支持 JAX-RS 2Client 端 API。Base Class 需要定义protected WebTarget webTarget和服务器初始化。测试 JAX-RS API 的唯一选项是启动 Web 服务器。同样,带有主体的请求需要设置 Content Type。否则,将使用默认值application/octet-stream

为了使用 JAX-RS 模式,请使用以下设置:

testMode == 'JAXRSCLIENT'

以下示例显示了生成的测试 API:

'''
 // when:
  Response response = webTarget
    .path("/users")
    .queryParam("limit", "10")
    .queryParam("offset", "20")
    .queryParam("filter", "email")
    .queryParam("sort", "name")
    .queryParam("search", "55")
    .queryParam("age", "99")
    .queryParam("name", "Denis.Stepanov")
    .queryParam("email", "[emailprotected]")
    .request()
    .method("GET");

  String responseAsString = response.readEntity(String.class);

 // then:
  assertThat(response.getStatus()).isEqualTo(200);
 // and:
  DocumentContext parsedJson = JsonPath.parse(responseAsString);
  assertThatJson(parsedJson).field("['property1']").isEqualTo("a");
'''

93.7 异步支持

如果您在服务器端使用异步通信(您的控制器返回CallableDeferredResult等),则在 Contract 内部,您必须在response部分中提供async()方法。以下代码显示了一个示例:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method GET()
        url '/get'
    }
    response {
        status OK()
        body 'Passed'
        async()
    }
}

YAML.

response:
    async: true

您还可以使用fixedDelayMilliseconds方法/属性来向存根添加延迟。

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method GET()
        url '/get'
    }
    response {
        status 200
        body 'Passed'
        fixedDelayMilliseconds 1000
    }
}

YAML.

response:
    fixedDelayMilliseconds: 1000

93.8 使用上下文路径

Spring Cloud Contract 支持上下文路径。

Tip

完全支持上下文路径所需的唯一更改是 PRODUCER 侧的开关。另外,自动生成的测试必须使用 EXPLICIT 模式。Consumer 方面保持不变。为了使生成的测试通过,您必须使用 EXPLICIT 模式。

Maven.

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

Gradle.

contracts {
		testMode = 'EXPLICIT'
}

这样,您生成的测试“不”使用 MockMvc。这意味着您要生成真实的请求,并且需要设置生成的测试的 Base Class 以在真实套接字上工作。

考虑以下 Contract:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'GET'
		url '/my-context-path/url'
	}
	response {
		status OK()
	}
}

以下示例显示如何设置 Base Class 和“确保放心”:

import io.restassured.RestAssured;
import org.junit.Before;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(classes = ContextPathTestingBaseClass.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ContextPathTestingBaseClass {

	@LocalServerPort int port;

	@Before
	public void setup() {
		RestAssured.baseURI = "http://localhost";
		RestAssured.port = this.port;
	}
}

如果您这样做:

  • 自动生成的测试中的所有请求都将发送到包含您的上下文路径的真实端点(例如/my-context-path/url)。

  • 您的 Contract 反映出您具有上下文路径。您生成的存根还具有该信息(例如,在存根中,您必须调用/my-context-path/url)。

93.9 使用 WebFlux

Spring Cloud Contract 提供了两种使用 WebFlux 的方法。

93.9.1 WebFlux 和 WebTestClient

其中之一是通过WebTestClient模式。

Maven.

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

Gradle.

contracts {
		testMode = 'WEBTESTCLIENT'
}

以下示例显示了如何为 WebFlux 设置WebTestClientBase Class 和RestAssured

import io.restassured.module.webtestclient.RestAssuredWebTestClient;
import org.junit.Before;

public abstract class BeerRestBase {

	@Before
	public void setup() {
		RestAssuredWebTestClient.standaloneSetup(
		new ProducerController(personToCheck -> personToCheck.age >= 20));
	}
}
}

93.9.2 具有显式模式的 WebFlux

另一种方法是在生成的测试中使用EXPLICIT模式来使用 WebFlux。

Maven.

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

Gradle.

contracts {
		testMode = 'EXPLICIT'
}

以下示例显示了如何为 Web Flux 设置 Base Class 和“确保 Rest”:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = BeerRestBase.Config.class,
		webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
		properties = "server.port=0")
public abstract class BeerRestBase {

    // your tests go here

    // in this config class you define all controllers and mocked services
@Configuration
@EnableAutoConfiguration
static class Config {

	@Bean
	PersonCheckingService personCheckingService()  {
		return personToCheck -> personToCheck.age >= 20;
	}

	@Bean
	ProducerController producerController() {
		return new ProducerController(personCheckingService());
	}
}

}

93.10 REST 的 XML 支持

对于 RESTContract,我们还支持 XML 请求和响应主体。 XML 正文必须作为StringGStringbody元素内传递。还可以为请求和响应提供身体匹配器。代替jsonPath(…)方法,应使用org.springframework.cloud.contract.spec.internal.BodyMatchers.xPath方法,将所需的xPath作为第一个参数,并将适当的MatchingType作为第二个参数。支持除byType()之外的所有身体匹配器。

这是带有 XML 响应主体的 Groovy DSLContract 的示例:

Contract.make {
				request {
					method GET()
					urlPath '/get'
					headers {
						contentType(applicationXml())
					}
				}
				response {
					status(OK())
					headers {
						contentType(applicationXml())
					}
					body """
<test>
<duck type='xtype'>123</duck>
<alpha>abc</alpha>
<list>
<elem>abc</elem>
<elem>def</elem>
<elem>ghi</elem>
</list>
<number>123</number>
<aBoolean>true</aBoolean>
<date>2017-01-01</date>
<dateTime>2017-01-01T01:23:45</dateTime>
<time>01:02:34</time>
<valueWithoutAMatcher>foo</valueWithoutAMatcher>
<key><complex>foo</complex></key>
</test>"""
					bodyMatchers {
						xPath('/test/duck/text()', byRegex("[0-9]{3}"))
						xPath('/test/duck/text()', byCommand('test($it)'))
						xPath('/test/duck/xxx', byNull())
						xPath('/test/duck/text()', byEquality())
						xPath('/test/alpha/text()', byRegex(onlyAlphaUnicode()))
						xPath('/test/alpha/text()', byEquality())
						xPath('/test/number/text()', byRegex(number()))
						xPath('/test/date/text()', byDate())
						xPath('/test/dateTime/text()', byTimestamp())
						xPath('/test/time/text()', byTime())
						xPath('/test/*/complex/text()', byEquality())
						xPath('/test/duck/@type', byEquality())
					}
				}
			}

下面是带有 XML 请求和响应主体的 YAMLContract 的示例:

include::{verifier_core_path}/src/test/resources/yml/contract_rest_xml.yml

这是自动生成的 XML 响应正文测试的示例:

@Test
public void validate_xmlMatches() throws Exception {
	// given:
	MockMvcRequestSpecification request = given()
				.header("Content-Type", "application/xml");

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

	// then:
	assertThat(response.statusCode()).isEqualTo(200);
	// and:
	DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance()
					.newDocumentBuilder();
	Document parsedXml = documentBuilder.parse(new InputSource(
				new StringReader(response.getBody().asString())));
	// and:
	assertThat(valueFromXPath(parsedXml, "/test/list/elem/text()")).isEqualTo("abc");
	assertThat(valueFromXPath(parsedXml,"/test/list/elem[2]/text()")).isEqualTo("def");
	assertThat(valueFromXPath(parsedXml, "/test/duck/text()")).matches("[0-9]{3}");
	assertThat(nodeFromXPath(parsedXml, "/test/duck/xxx")).isNull();
	assertThat(valueFromXPath(parsedXml, "/test/alpha/text()")).matches("[\\p{L}]*");
	assertThat(valueFromXPath(parsedXml, "/test/*/complex/text()")).isEqualTo("foo");
	assertThat(valueFromXPath(parsedXml, "/test/duck/@type")).isEqualTo("xtype");
	}

93.11 向顶层元素发送消息

用于消息传递的 DSL 与专注于 HTTP 的 DSL 看起来有些不同。以下各节说明了差异:

93.11.1 方法触发的输出

可以通过调用方法(例如启动 a 并发送消息时使用Scheduler)来触发输出消息,如以下示例所示:

Groovy DSL.

def dsl = Contract.make {
	// Human readable description
	description 'Some description'
	// Label by means of which the output message can be triggered
	label 'some_label'
	// input to the contract
	input {
		// the contract will be triggered by a method
		triggeredBy('bookReturnedTriggered()')
	}
	// output message of the contract
	outputMessage {
		// destination to which the output message will be sent
		sentTo('output')
		// the body of the output message
		body('''{ "bookName" : "foo" }''')
		// the headers of the output message
		headers {
			header('BOOK-NAME', 'foo')
		}
	}
}

YAML.

# Human readable description
description: Some description
# Label by means of which the output message can be triggered
label: some_label
input:
  # the contract will be triggered by a method
  triggeredBy: bookReturnedTriggered()
# output message of the contract
outputMessage:
  # destination to which the output message will be sent
  sentTo: output
  # the body of the output message
  body:
    bookName: foo
  # the headers of the output message
  headers:
    BOOK-NAME: foo

在前面的示例中,如果执行了名为bookReturnedTriggered的方法,则输出消息将发送到output。在消息发布者侧,我们生成了一个测试,该测试调用该方法来触发消息。在 Consumer 端,您可以使用some_label触发消息。

93.11.2 消息触发的输出

可以通过接收消息来触发输出消息,如以下示例所示:

Groovy DSL.

def dsl = Contract.make {
	description 'Some Description'
	label 'some_label'
	// input is a message
	input {
		// the message was received from this destination
		messageFrom('input')
		// has the following body
		messageBody([
		        bookName: 'foo'
		])
		// and the following headers
		messageHeaders {
			header('sample', 'header')
		}
	}
	outputMessage {
		sentTo('output')
		body([
		        bookName: 'foo'
		])
		headers {
			header('BOOK-NAME', 'foo')
		}
	}
}

YAML.

# Human readable description
description: Some description
# Label by means of which the output message can be triggered
label: some_label
# input is a message
input:
  messageFrom: input
  # has the following body
  messageBody:
    bookName: 'foo'
  # and the following headers
  messageHeaders:
    sample: 'header'
# output message of the contract
outputMessage:
  # destination to which the output message will be sent
  sentTo: output
  # the body of the output message
  body:
    bookName: foo
  # the headers of the output message
  headers:
    BOOK-NAME: foo

在前面的示例中,如果在input目标上接收到正确的消息,则将输出消息发送到output。在消息发布者侧,引擎生成一个测试,将该 Importing 消息发送到已定义的目标。在“Consumer”端,您可以将消息发送到 Importing 目标,也可以使用标签(示例中为some_label)来触发消息。

93.11.3 Consumer/Producer

Tip

本节仅对 Groovy DSL 有效。

在 HTTP 中,您具有client /stub and `server/ test表示法的概念。您也可以在消息传递中使用这些范例。此外,Spring Cloud Contract Verifier 还提供了consumerproducer方法,如以下示例所示(请注意,您可以使用$value方法来提供consumerproducer部分):

Contract.make {
	label 'some_label'
	input {
		messageFrom value(consumer('jms:output'), producer('jms:input'))
		messageBody([
				bookName: 'foo'
		])
		messageHeaders {
			header('sample', 'header')
		}
	}
	outputMessage {
		sentTo $(consumer('jms:input'), producer('jms:output'))
		body([
				bookName: 'foo'
		])
	}
}

93.11.4 Common

inputoutputMessage部分中,您可以使用在 Base Class 或静态导入中定义的method(例如assertThatMessageIsOnTheQueue())的名称来调用assertThat。 Spring Cloud Contract 将在生成的测试中执行该方法。

93.12 一个文件中包含多个 Contract

您可以在一个文件中定义多个 Contract。这样的 Contract 可能类似于以下示例:

Groovy DSL.

import org.springframework.cloud.contract.spec.Contract

[
        Contract.make {
            name("should post a user")
            request {
                method 'POST'
                url('/users/1')
            }
            response {
                status OK()
            }
        },
        Contract.make {
            request {
                method 'POST'
                url('/users/2')
            }
            response {
                status OK()
            }
        }
]

YAML.

---
name: should post a user
request:
  method: POST
  url: /users/1
response:
  status: 200
---
request:
  method: POST
  url: /users/2
response:
  status: 200
---
request:
  method: POST
  url: /users/3
response:
  status: 200

在前面的示例中,一个 Contract 具有name字段,而另一个则没有。这导致生成两个看起来或多或少像这样的测试:

package org.springframework.cloud.contract.verifier.tests.com.hello;

import com.example.TestBase;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.jayway.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import com.jayway.restassured.response.ResponseOptions;
import org.junit.Test;

import static com.jayway.restassured.module.mockmvc.RestAssuredMockMvc.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;

public class V1Test extends TestBase {

	@Test
	public void validate_should_post_a_user() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();

		// when:
			ResponseOptions response = given().spec(request)
					.post("/users/1");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
	}

	@Test
	public void validate_withList_1() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();

		// when:
			ResponseOptions response = given().spec(request)
					.post("/users/2");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
	}

}

请注意,对于具有name字段的 Contract,生成的测试方法名为validate_should_post_a_user。对于一个没有名称的名称,它称为validate_withList_1。它对应于文件WithList.groovy的名称以及列表中 Contract 的索引。

下例显示了生成的存根:

should post a user.json
1_WithList.json

如您所见,第一个文件从 Contract 中获得了name参数。第二个得到的 Contract 文件名(WithList.groovy)带有索引前缀(在这种情况下,Contract 在文件中的 Contract 列表中的索引为1)。

Tip

如您所见,命名 Contract 会更好,因为这样做会使测试更有意义。

93.13 从 Contract 中生成 Spring REST Docs 片段

当您想使用 Spring REST Docs 包含 API 的请求和响应时,如果使用 MockMvc 和 RestAssuredMockMvc,则只需要对设置进行一些小的更改。如果还没有,只需包括以下依赖项。

Maven.

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

Gradle.

testCompile 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc'

接下来,您需要对 Base Class 进行一些更改,例如以下示例。

package com.example.fraud;

import io.restassured.module.mockmvc.RestAssuredMockMvc;

import org.junit.Before;
import org.junit.Rule;
import org.junit.rules.TestName;
import org.junit.runner.RunWith;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public abstract class FraudBaseWithWebAppSetup {

	private static final String OUTPUT = "target/generated-snippets";

	@Rule
	public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation(OUTPUT);

	@Rule public TestName testName = new TestName();

	@Autowired
	private WebApplicationContext context;

	@Before
	public void setup() {
	RestAssuredMockMvc.mockMvc(MockMvcBuilders.webAppContextSetup(this.context)
			.apply(documentationConfiguration(this.restDocumentation))
			.alwaysDo(document(getClass().getSimpleName() + "_" + testName.getMethodName()))
			.build());
	}

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

如果您使用的是独立安装程序,则可以这样设置 RestAssuredMockMvc:

package com.example.fraud;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;
import org.junit.Rule;
import org.junit.rules.TestName;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;

public abstract class FraudBaseWithStandaloneSetup {

	private static final String OUTPUT = "target/generated-snippets";

	@Rule
	public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation(OUTPUT);

	@Rule public TestName testName = new TestName();

	@Before
	public void setup() {
		RestAssuredMockMvc.standaloneSetup(MockMvcBuilders.standaloneSetup(new FraudDetectionController())
				.apply(documentationConfiguration(this.restDocumentation))
				.alwaysDo(document(getClass().getSimpleName() + "_" + testName.getMethodName())));
	}

}

Tip

从 Spring REST Docs 的 1.2.0.RELEASE 版本开始,您无需为生成的代码片段指定输出目录。