87. Contract DSL

Spring Cloud Contract 支持开箱即用的 2 种 DSL。一个写在Groovy,一个写在YAML

如果你决定在 Groovy 中编写 contract,如果之前没有使用过 Groovy,请不要惊慌。由于 Contract DSL 仅使用它的一小部分(仅_lite,方法 calls 和闭包),因此不需要对语言的了解。此外,DSL 是静态类型的,以便在不知道 DSL 本身的情况下使其成为 programmer-readable。

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

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

以下是 Groovy contract 定义的完整示例:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'PUT'
		url '/api/12'
		headers {
			header 'Content-Type': 'application/vnd.org.springframework.cloud.contract.verifier.twitter-places-analyzer.v1+json'
		}
		body '''\
		[{
			"created_at": "Sat Jul 26 09:38:57 +0000 2014",
			"id": 492967299297845248,
			"id_str": "492967299297845248",
			"text": "Gonna see you at Warsaw",
			"place":
			{
				"attributes":{},
				"bounding_box":
				{
					"coordinates":
						[[
							[-77.119759,38.791645],
							[-76.909393,38.791645],
							[-76.909393,38.995548],
							[-77.119759,38.995548]
						]],
					"type":"Polygon"
				},
				"country":"United States",
				"country_code":"US",
				"full_name":"Washington, DC",
				"id":"01fbe706f872cb32",
				"name":"Washington",
				"place_type":"city",
				"url": "http://api.twitter.com/1/geo/id/01fbe706f872cb32.json"
			}
		}]
	'''
	}
	response {
		status 200
	}
}

以下是 YAML contract 定义的完整示例:

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
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)

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

87.1 限制

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

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

由于 JSON 结构可以具有任何形式,因此在GString中使用 Groovy DSL 和value(consumer(…), producer(…))表示法时,无法正确解析它。这就是你应该使用 Groovy Map 表示法的原因。

87.2 Common Top-Level 元素

以下部分描述了最常见的 top-level 元素:

87.2.1 说明

您可以在 contract 中添加description。描述是任意文本。以下 code 显示了一个 example:

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
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)

87.2.2 Name

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

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

Groovy DSL.

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

YAML.

name: some name

87.2.3 忽略 Contracts

如果要忽略 contract,可以在插件 configuration 中设置忽略 contracts 的 value,或者在 contract 本身上设置ignored property:

Groovy DSL.

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

YAML.

ignored: true

87.2.4 从 Files 传递值

从 version 1.2.0开始,您可以从 files 传递值。假设您的项目中包含以下资源。

└── 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 200
		body(file("response.json"))
		headers {
			contentType(textPlain())
		}
	}
}

YAML.

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

进一步假设 JSON files 如下:

request.json

{ "status" : "REQUEST" }

response.json

{ "status" : "RESPONSE" }

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

87.2.5 HTTP Top-Level Elements

可以在 contract 定义的 top-level 闭包中调用以下方法。 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 {
		//...
	}

	// 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 {
		//...
	}

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

YAML.

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

如果你想让你的 contract 具有更高的值优先级,你需要将号传递给priority标签/方法。 E.g。 与 value 5具有比更高**优先级priority

87.3 请求

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 {
		//...
	}
}

YAML.

method: PUT
url: /foo

可以指定绝对而不是相对url,但是建议使用urlPath,因为这样做会使测试host-independent

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 {
		//...
	}
}

YAML.

request:
  method: PUT
  urlPath: /foo

request可能包含查询参数

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...

		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 {
		//...
	}
}

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
  headers:
    foo2: bar
    foo3: foo33
    fooRes: baz
  body:
    foo2: bar
    foo3: baz
  matchers:
    body:
      - path: $.foo2
        type: by_regex
        value: bar
      - path: $.foo3
        type: by_command
        value: executeMe($it)
    headers:
      - key: foo2
        regex: bar
      - key: foo3
        command: andMeToo($it)
    cookies:
      - key: foo2
        regex: bar
      - key: foo3
        predefined:

request可能包含额外的请求 headers,如下面的示例所示:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...

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

		//...
	}

	response {
		//...
	}
}

YAML.

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

request可能包含额外的请求 cookies,如下例所示 example:

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...

		// 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 {
		//...
	}
}

YAML.

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

request可能包含请求正文

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...

		// 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 {
		//...
	}
}

YAML.

request:
...
body:
  foo: bar

request可能包含multipart元素。要包含 multipart 元素,请使用multipart method/section,如以下示例所示

Groovy DSL.

org.springframework.cloud.contract.spec.Contract contractDsl = org.springframework.cloud.contract.spec.Contract.make {
	request {
		method "PUT"
		url "/multipart"
		headers {
			contentType('multipart/form-data;boundary=AaB03x')
		}
		multipart(
				// key (parameter name), value (parameter value) pair
				formParameter: $(c(regex('".+"')), p('"formParameterValue"')),
				someBooleanParameter: $(c(regex(anyBoolean())), p('true')),
				// a named parameter (e.g. with `file` name) that represents file with
				// `name` and `content`. You can also call `named("fileName", "fileContent")`
				file: named(
						// name of the file
						name: $(c(regex(nonEmpty())), p('filename.csv')),
						// content of the file
						content: $(c(regex(nonEmpty())), p('file content')),
						// content type for the part
						contentType: $(c(regex(nonEmpty())), p('application/json')))
		)
	}
	response {
		status 200
	}
}

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

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

Groovy DSL

  • 直接使用 map 表示法,其中 value 可以是动态 property(例如formParameter: $(consumer(…), producer(…)))。

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

**** YAML

  • multipart 参数通过multipart.params section 设置

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

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

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

  • 对于命名参数,使用named部分,首先通过paramName定义参数 name 然后你可以通过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-Length: \\\\d+\\r\\n)?\\r\\n\\".+\\"\\r\\n--\\\\1.*"
  		}, {
    			"matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"someBooleanParameter\\"\\r\\n(Content-Type: .*\\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-Length: \\\\d+\\r\\n)?\\r\\n[\\\\S\\\\s]+\\r\\n--\\\\1.*"
	} ]
  },
  "response" : {
	"status" : 200,
	"transformers" : [ "response-template", "foo-transformer" ]
  }
}
	'''

87.4 回复

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

Groovy DSL.

org.springframework.cloud.contract.spec.Contract.make {
	request {
		//...
	}
	response {
		// Status code sent by the server
		// in response to request specified above.
		status 200
	}
}

YAML.

response:
...
status: 200

除状态外,响应可能包含headerscookiesbody,两者的指定方式与请求中的相同(参见上一段)。

87.5 动态 properties

contract 可以包含一些动态 properties:时间戳,ID 等。您不希望强制消费者将其时钟存根,以便始终 return time_time 的相同 value,以便它与存根匹配。

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

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

87.5.1 体内的动态 properties

本节仅适用于 Groovy DSL。查看第 87.5.7 节,“匹配器部分中的动态 Properties”部分,了解类似 feature 的 YAML 示例。

您可以使用value方法在主体内设置 properties,或者如果使用 Groovy map 表示法,则使用$()。以下 example 显示了如何使用 value 方法设置动态 properties:

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

以下 example 显示了如何使用$()设置动态 properties:

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

两种方法都同样有效。 stubclient方法是consumer方法的别名。后续部分将详细介绍您可以对这些值执行的操作。

87.5.2 正则表达式

本节仅适用于 Groovy DSL。查看第 87.5.7 节,“匹配器部分中的动态 Properties”部分,了解类似 feature 的 YAML 示例。

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

以下 example 显示了如何使用正则表达式来编写请求:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method('GET')
		url $(consumer(~/\/[0-9]{2}/), producer('/12'))
	}
	response {
		status 200
		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 引擎会自动提供与提供的正则表达式匹配的生成的 string。以下 code 显示了一个 example:

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 200
		body([
			responseElement: $(producer(regex('[0-9]{7}')))
		])
		headers {
			contentType("application/vnd.fraud.v1+json")
		}
	}
}

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

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

protected static final Pattern TRUE_OR_FALSE = Pattern.compile(/(true|false)/)
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 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 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("|"))
}

String onlyAlphaUnicode() {
	return ONLY_ALPHA_UNICODE.pattern()
}

String number() {
	return NUMBER.pattern()
}

String anyBoolean() {
	return TRUE_OR_FALSE.pattern()
}

String ipAddress() {
	return IP_ADDRESS.pattern()
}

String hostname() {
	return HOSTNAME_PATTERN.pattern()
}

String email() {
	return EMAIL.pattern()
}

String url() {
	return URL.pattern()
}

String uuid(){
	return UUID.pattern()
}

String isoDate() {
	return ANY_DATE.pattern()
}

String isoDateTime() {
	return ANY_DATE_TIME.pattern()
}

String isoTime() {
	return ANY_TIME.pattern()
}

String iso8601WithOffset() {
	return ISO8601_WITH_OFFSET.pattern()
}

String nonEmpty() {
	return NON_EMPTY.pattern()
}

String nonBlank() {
	return NON_BLANK.pattern()
}

在 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]'))}]"
		)
	}
}

87.5.3 传递可选参数

本节仅适用于 Groovy DSL。查看第 87.5.7 节,“匹配器部分中的动态 Properties”部分,了解类似 feature 的 YAML 示例。

可以在 contract 中提供可选参数。但是,您只能为以下内容提供可选参数:

  • STUB 方面的请求

  • 响应的 TEST 侧

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

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,将从前一个 example 生成以下测试:

"""
 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
}
'''

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

本节仅适用于 Groovy DSL。查看第 87.5.7 节,“匹配器部分中的动态 Properties”部分,了解类似 feature 的 YAML 示例。

您可以定义在测试期间在服务器端执行的方法调用。这样的方法可以添加到_conflass 中定义为“baseClassForTests”的 class 中。以下 code 显示了测试用例的 contract 部分的 example:

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 200
	}
}

以下 code 显示了测试用例的 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
	}

}

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

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

  • String:如果指向 JSON 中的String value。

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

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

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

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

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

您必须提供 consumer 和 producer 端。 execute部分适用于整个身体 - 不适用于部分身体。

以下 example 显示了如何从 JSON 读取 object:

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

前面的 example 导致在请求正文中调用hashCode()方法。它应该类似于以下 code:

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

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

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

87.5.5 引用响应请求

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

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

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

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

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

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

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

  • fromRequest().header(String key):返回给定 name 的第一个标头。

  • fromRequest().header(String key, int index):返回给定 name 的第 n 个标头。

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

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

如果您正在使用 YAML contract 定义,则必须使用把手 {{{ }}}表示法和自定义的 Spring Cloud Contract 函数来实现此目的。

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

  • {{{ request.query.key.[index] }}}:返回具有给定 name 的第 n 个查询参数。 E.g。 for key foo,第一个条目{{{ request.query.foo.[0] }}}

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

  • {{{ request.path.[index] }}}:返回第 n 个路径元素。 E.g。第一次进入```{{{request.path。[218]}}}

  • {{{ request.headers.key }}}:返回给定 name 的第一个标头。

  • {{{ request.headers.key.[index] }}}:返回给定 name 的第 n 个标头。

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

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

考虑以下 contract:

Groovy DSL.

Contract contractDsl = Contract.make {
	request {
		method 'GET'
		url('/api/v1/xxxx') {
			queryParameters {
				parameter("foo", "bar")
				parameter("foo", "bar2")
			}
		}
		headers {
			header(authorization(), "secret")
			header(authorization(), "secret2")
		}
		body(foo: "bar", baz: 5)
	}
	response {
		status 200
		headers {
			header(authorization(), "foo ${fromRequest().header(authorization())} bar")
		}
		body(
				url: fromRequest().url(),
				path: fromRequest().path(),
				pathIndex: fromRequest().path(1),
				param: fromRequest().query("foo"),
				paramIndex: fromRequest().query("foo", 1),
				authorization: fromRequest().header("Authorization"),
				authorization2: fromRequest().header("Authorization", 1),
				fullBody: fromRequest().body(),
				responseFoo: fromRequest().body('$.foo'),
				responseBaz: fromRequest().body('$.baz'),
				responseBaz2: "Bla bla ${fromRequest().body('$.foo')} bla bla"
		)
	}
}

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 测试生成会导致类似以下 example 的测试:

// 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 存根应类似于以下 example:

{
  "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"
}

此 feature 仅适用于 version 大于或等于 2.5.1 的 WireMock。 Spring Cloud Contract Verifier 使用 WireMock 的response-template响应转换器。它使用 Handlebars 将 Mustache {{{ }}}模板转换为适当的值。另外,它注册了两个辅助函数:

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

  • jsonpath:对于给定参数,请在请求正文中查找 object。

87.5.6 注册自己的 WireMock 扩展

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

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

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

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"
	}
}

如果希望仅将转换应用于明确需要它的映射,请记住覆盖applyGlobally()方法并将其设置为false

87.5.7 匹配器部分中的动态 Properties

如果您使用协议,以下讨论可能看起来很熟悉。相当多的用户习惯于在身体之间进行分离并设置 contract 的动态部分。

您可以使用两个单独的部分:

  • stubMatchers,它允许您定义应该在存根中结束的动态值。您可以在 contract 的requestinputMessage部分进行设置。

  • testMatchers,它出现在 contract 的responseoutputMessage侧。

目前,Spring Cloud Contract Verifier 仅支持具有以下匹配可能性的 JSON Path-based 匹配器:

Groovy DSL

  • 对于stubMatchers

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

  • byRegex(…):通过提供的 JSON 路径从请求中获取的 value 必须 match 正则表达式。

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

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

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

  • 对于testMatchers

  • byEquality():通过提供的 JSON 路径从响应中获取的 value 必须等于 contract 中提供的 value。

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

  • byDate():通过提供的 JSON 路径从响应中获取的 value 必须匹配 ISO Date value 的正则表达式。

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

  • byTime():通过提供的 JSON 路径从响应中获取的 value 必须匹配 ISO Time value 的正则表达式。

  • byType():通过提供的 JSON 路径从响应中获取的 value 需要与 contract 中响应主体中定义的类型相同。 byType可以采用闭包,您可以在其中设置minOccurrencemaxOccurrence。这样,您可以断言拼合集合的大小。要检查 unflattened 集合的大小,请使用带有byCommand(…) testMatcher 的自定义方法。

  • byCommand(…):通过提供的 JSON 路径从响应中获取的 value 作为输入传递给您提供的自定义方法。对于 example,byCommand('foo($it)')会导致调用与 JSON 路径匹配的 value 传递到的foo方法。从 JSON 读取的 object 的类型可以是以下之一,具体取决于 JSON 路径:

  • String:如果指向String value。

  • JSONArray:如果指向List

  • Map:如果指向Map

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

  • Boolean:如果指向Boolean

**YAML.**请阅读 Groovy 部分,详细说明类型的含义

对于 YAML,匹配器的结构看起来像这样

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

或者,如果要使用其中一个预定义的正则表达式[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

您可以在下面找到允许的type s 列表。

  • 对于stubMatchers

  • by_equality

  • by_regex

  • by_date

  • by_timestamp

  • by_time

  • 对于testMatchers

  • by_equality

  • by_regex

  • by_date

  • by_timestamp

  • by_time

  • by_type

  • 接受了另外两个字段:minOccurrencemaxOccurrence

  • by_command

考虑以下 example:

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'
				]
		])
		stubMatchers {
			jsonPath('$.duck', byRegex("[0-9]{3}"))
			jsonPath('$.duck', byEquality())
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()))
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()))
			jsonPath('$.aBoolean', byRegex(anyBoolean()))
			jsonPath('$.date', byDate())
			jsonPath('$.dateTime', byTimestamp())
			jsonPath('$.time', byTime())
			jsonPath("\$.['key'].['complex.key']", byEquality())
		}
		headers {
			contentType(applicationJson())
		}
	}
	response {
		status 200
		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'
				]
		])
		testMatchers {
			// asserts the jsonpath value against manual regex
			jsonPath('$.duck', byRegex("[0-9]{3}"))
			// asserts the jsonpath value against the provided value
			jsonPath('$.duck', byEquality())
			// asserts the jsonpath value against some default regex
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()))
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()))
			jsonPath('$.aBoolean', byRegex(anyBoolean()))
			// 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())
		}
		headers {
			contentType(applicationJson())
		}
	}
}

YAML.

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'
  matchers:
    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
  headers:
    Content-Type: application/json
response:
  status: 200
  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'
  matchers:
    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: $.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)
  headers:
    Content-Type: application/json

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

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

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

  • 对于$.valueWithMin,引擎检查类型并断言大小是否大于或等于最小值。

  • 对于$.valueWithMax,引擎检查类型并断言大小是否小于或等于最大值。

  • 对于$.valueWithMinMax,引擎检查类型并断言大小是否在最小和最大出现之间。

生成的测试类似于以下 example(请注意,and部分将自动生成的断言与断言与匹配器分开):

// 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\"}");

// 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+)?");
 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)).hasSizeGreaterThanOrEqualTo(1);
 assertThat((Object) parsedJson.read("$.valueWithMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMax", java.util.Collection.class)).hasSizeLessThanOrEqualTo(3);
 assertThat((Object) parsedJson.read("$.valueWithMinMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinMax", java.util.Collection.class)).hasSizeBetween(1, 3);
 assertThat((Object) parsedJson.read("$.valueWithMinEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinEmpty", java.util.Collection.class)).hasSizeGreaterThanOrEqualTo(0);
 assertThat((Object) parsedJson.read("$.valueWithMaxEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMaxEmpty", java.util.Collection.class)).hasSizeLessThanOrEqualTo(0);
 assertThatValueIsANumber(parsedJson.read("$.duck"));

请注意,对于byCommand方法,example calls assertThatValueIsANumber。此方法必须在 test base class 中定义,或者静态导入到测试中。请注意byCommand调用已转换为assertThatValueIsANumber(parsedJson.read("$.duck"));。这意味着引擎采用方法 name 并将适当的 JSON 路径作为参数传递给它。

生成的 WireMock 存根在以下 example 中:

'''
{
  "request" : {
	"urlPath" : "/get",
	"method" : "POST",
	"headers" : {
	  "Content-Type" : {
		"matches" : "application/json.*"
	  }
	},
	"bodyPatterns" : [ {
	  "matchesJsonPath" : "$[?(@.['valueWithoutAMatcher'] == 'foo')]"
	}, {
	  "matchesJsonPath" : "$[?(@.['valueWithTypeMatch'] == 'string')]"
	}, {
	  "matchesJsonPath" : "$.['list'].['some'].['nested'][?(@.['anothervalue'] == 4)]"
	}, {
	  "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['anothervalue'] == 4)]"
	}, {
	  "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['json'] == 'with value')]"
	}, {
	  "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 =~ /(.*)/)]"
	} ]
  },
  "response" : {
	"status" : 200,
	"body" : "{\\"date\\":\\"2017-01-01\\",\\"dateTime\\":\\"2017-01-01T01:23:45\\",\\"number\\":123,\\"aBoolean\\":true,\\"duck\\":123,\\"alpha\\":\\"abc\\",\\"valueWithMin\\":[1,2,3],\\"time\\":\\"01:02:34\\",\\"valueWithTypeMatch\\":\\"string\\",\\"valueWithMax\\":[1,2,3],\\"valueWithMinMax\\":[1,2,3],\\"valueWithoutAMatcher\\":\\"foo\\"}",
	"headers" : {
	  "Content-Type" : "application/json"
	}
  }
}
'''

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

考虑以下 example:

Contract.make {
    request {
        method 'GET'
        url("/foo")
    }
    response {
        status 200
        body(events: [[
                                 operation          : 'EXPORT',
                                 eventId            : '16f1ed75-0bcc-4f0d-a04d-3121798faf99',
                                 status             : 'OK'
                         ], [
                                 operation          : 'INPUT_PROCESSING',
                                 eventId            : '3bb4ac82-6652-462f-b6d1-75e424a0024a',
                                 status             : 'OK'
                         ]
                ]
        )
        testMatchers {
            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('.+'))
        }
    }
}

前面的 code 导致 creating 以下测试(code 块只显示断言部分):

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(".+")

如您所见,断言是错误的。只有 array 的第一个元素被断言。在 order 中修复此问题,您应该将断言应用于整个$.events集合并使用byCommand(…)方法断言它。

87.6 JAX-RS 支持

Spring Cloud Contract Verifier 支持 JAX-RS 2 Client API。 base class 需要定义protected WebTarget webTarget和服务器初始化。测试 JAX-RS API 的唯一选择是启动 web 服务器。此外,具有正文的请求需要设置 content type。否则,将使用默认值application/octet-stream

在 order 中使用 JAX-RS 模式,请使用以下设置:

testMode == 'JAXRSCLIENT'

以下 example 显示了生成的测试 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");
'''

87.7 异步支持

如果您在服务器端使用异步通信(控制器正在返回CallableDeferredResult等),那么在 contract 中,您必须在response部分中提供sync()方法。以下 code 显示了一个 example:

Groovy DSL.

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

YAML.

response:
    async: true

87.8 使用 Context Paths

Spring Cloud Contract 支持 context paths。

完全支持 context paths 所需的唯一更改是PRODUCER侧的开关。此外,自动生成的测试必须使用EXPLICIT模式。 consumer 方面保持不变。在 order 中生成的测试要通过,您必须使用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>

摇篮.

contracts {
		testMode = 'EXPLICIT'
}

这样,您生成的测试不会使用 MockMvc。这意味着您生成实际请求,并且您需要设置生成的测试的 base class 以在真正的 socket 上工作。

考虑以下 contract:

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

以下 example 显示了如何设置 base class 和 Rest Assured:

import io.restassured.RestAssured;
import org.junit.Before;
import org.springframework.boot.context.embedded.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;
	}
}

如果你这样做:

  • 自动生成的测试中的所有请求都会发送到包含 context 路径的真实端点(对于 example,/my-context-path/url)。

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

87.9 消息 Top-Level 元素

用于消息传递的 DSL 看起来与关注 HTTP 的有点不同。以下部分解释了这些差异:

87.9.1 由方法触发的输出

可以通过调用方法(例如启动时发送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

在前面的 example 情况下,如果执行了一个名为bookReturnedTriggered的方法,则输出消息将发送到output。在消息发布者方面,我们生成一个测试 calls 该方法来触发消息。在consumer侧,您可以使用some_label来触发消息。

87.9.2 由消息触发的输出

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

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

在前面的 example 中,如果在input目标上收到正确的消息,则输出消息将发送到output。在消息发布者侧,引擎生成一个测试,将输入消息发送到定义的目标。在consumer侧,您可以向输入目标发送消息或使用标签(example 中的some_label)来触发消息。

87.9.3 Consumer/Producer

本节仅适用于 Groovy DSL。

在 HTTP 中,您有一个client/stub and server / test notation. You can also use those paradigms in messaging. In addition, Spring Cloud Contract Verifier also provides the consumer and producer methods, as presented in the following example (note that you can use either $ value methods to provide consumer and producer`部分)的概念:

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'
		])
	}
}

87.9.4 Common

inputoutputMessage部分中,您可以使用 base class 中定义的method(e.g. assertThatMessageIsOnTheQueue())的 name 或静态 import 调用assertThat。 Spring Cloud Contract 将在生成的测试中执行该方法。

87.10 在一个文件中有多个 Contracts

您可以在一个文件中定义多个 contracts。这样的 contract 可能类似于以下 example:

Groovy DSL.

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

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

YAML.

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

---
request:
  method: POST
  url: /users/2
response:
  status: 200

在前面的 example 中,一个 contract 具有name字段而另一个不具有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。对于没有 name 的那个,它被称为validate_withList_1。它对应于文件WithList.groovy的 name 和列表中 contract 的索引。

生成的存根显示在以下 example 中:

should post a user.json
1_WithList.json

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

正如你所看到的,如果你 name 你的 contracts 会更好,因为这样做会让你的测试更有意义。