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 数组大小的支持是实验性的。如果要打开它,请将以下系统属性的值设置为true
:spring.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 定义的顶级闭合中可以调用以下方法。 request
和response
为必填项。 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
标签/方法。例如。值5
的priority
的优先级高于值10
的priority
。
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(…)
方法,您可以设置命名参数。命名参数可以设置name
和content
。您可以通过带有两个参数的方法(例如named("fileName", "fileContent")
)或通过 Map 符号(例如named(name: "fileName", content: "fileContent")
)来调用它。
YAML
-
通过
multipart.params
部分设置 Multipart 参数 -
可以通过
multipart.named
部分设置命名参数(给定参数名称的fileName
和fileContent
)。该部分包含paramName
(参数名称),fileName
(文件名称),fileContent
(文件内容)字段 -
可以通过
matchers.multipart
部分设置动态位 -
对于参数,请使用可以接受
regex
或predefined
正则表达式的params
部分- 对于命名参数,请使用
named
部分,其中首先通过paramName
定义参数名称,然后可以通过regex
或predefined
正则表达式传递fileName
或fileContent
的参数化
- 对于命名参数,请使用
根据该 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()
来获取状态200
或BAD_REQUEST()
来获取400
。
93.5 动态属性
Contract 可以包含一些动态属性:时间戳记,ID 等。您不想强迫使用者将自己的时钟设置为总是返回相同的时间值,以使该时间与该存根匹配。
对于 Groovy DSL,您可以通过两种方式在 Contract 中提供动态部分:将它们直接传递到正文中,或在单独的部分bodyMatchers
中进行设置。
Note
在 2.0.0 之前,这些版本是使用testMatchers
和stubMatchers
设置的,请查阅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(...))
两种方法都同样有效。 stub
和client
方法是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 中的Integer
,Double
等。 -
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 的
request
或inputMessage
部分进行设置。 -
验证测试结果。此部分位于 Contract 的
response
或outputMessage
一侧。
当前,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
可以关闭,您可以在其中设置minOccurrence
和maxOccurrence
。对于请求端,应使用闭包声明集合的大小。这样,您可以声明展平集合的大小。要检查未展平的集合的大小,请对byCommand(…)
testMatcher 使用自定义方法。 -
byCommand(…)
:通过提供的 JSON 路径从生产者的响应中获取的值作为 Importing 传递到您提供的自定义方法。例如,byCommand('foo($it)')
导致调用foo
方法,该方法将与 JSON Path 匹配的值传递给该方法。从 JSON 读取的对象的类型可以是以下之一,具体取决于 JSON 路径:
-
-
String
:如果指向String
值。-
JSONArray
:如果您指向List
。 -
Map
:如果您指向Map
。 -
Number
:如果您指向Integer
,Double
或其他类型的数字。 -
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 个其他字段:
minOccurrence
和maxOccurrence
。 -
对于
testMatchers
: -
by_equality
-
by_regex
-
by_date
-
by_timestamp
-
by_time
-
by_type
-
-
还有 2 个其他字段:
minOccurrence
和maxOccurrence
。-
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 异步支持
如果您在服务器端使用异步通信(您的控制器返回Callable
,DeferredResult
等),则在 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 设置WebTestClient
Base 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 正文必须作为String
或GString
在body
元素内传递。还可以为请求和响应提供身体匹配器。代替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 还提供了consumer
和producer
方法,如以下示例所示(请注意,您可以使用$
或value
方法来提供consumer
和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'
])
}
}
93.11.4 Common
在input
或outputMessage
部分中,您可以使用在 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 版本开始,您无需为生成的代码片段指定输出目录。