Spring Cloud Contract 功能
1. 合同 DSL
Spring Cloud Contract 支持以下语言编写的 DSL:
-
槽的
-
YAML
-
Java
-
Kotlin
Spring Cloud Contract 支持在单个文件中定义多个 Contract。 |
以下示例显示了 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": "https://api.twitter.com/1/geo/id/01fbe706f872cb32.json"
}
}]
'''
}
response {
status OK()
}
}
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)
import java.util.Collection;
import java.util.Collections;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.verifier.util.ContractVerifierUtil;
class contract_rest implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.description("Some description");
c.name("some name");
c.priority(8);
c.ignored();
c.request(r -> {
r.url("/foo", u -> {
u.queryParameters(q -> {
q.parameter("a", "b");
q.parameter("b", "c");
});
});
r.method(r.PUT());
r.headers(h -> {
h.header("foo", r.value(r.client(r.regex("bar")), r.server("bar")));
h.header("fooReq", "baz");
});
r.body(ContractVerifierUtil.map().entry("foo", "bar"));
r.bodyMatchers(m -> {
m.jsonPath("$.foo", m.byRegex("bar"));
});
});
c.response(r -> {
r.fixedDelayMilliseconds(1000);
r.status(r.OK());
r.headers(h -> {
h.header("foo2", r.value(r.server(r.regex("bar")), r.client("bar")));
h.header("foo3", r.value(r.server(r.execute("andMeToo($it)")),
r.client("foo33")));
h.header("fooRes", "baz");
});
r.body(ContractVerifierUtil.map().entry("foo2", "bar")
.entry("foo3", "baz").entry("nullValue", null));
r.bodyMatchers(m -> {
m.jsonPath("$.foo2", m.byRegex("bar"));
m.jsonPath("$.foo3", m.byCommand("executeMe($it)"));
m.jsonPath("$.nullValue", m.byNull());
});
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
import org.springframework.cloud.contract.spec.withQueryParameters
contract {
name = "some name"
description = "Some description"
priority = 8
ignored = true
request {
url = url("/foo") withQueryParameters {
parameter("a", "b")
parameter("b", "c")
}
method = PUT
headers {
header("foo", value(client(regex("bar")), server("bar")))
header("fooReq", "baz")
}
body = body(mapOf("foo" to "bar"))
bodyMatchers {
jsonPath("$.foo", byRegex("bar"))
}
}
response {
delay = fixedMilliseconds(1000)
status = OK
headers {
header("foo2", value(server(regex("bar")), client("bar")))
header("foo3", value(server(execute("andMeToo(\$it)")), client("foo33")))
header("fooRes", "baz")
}
body = body(mapOf(
"foo" to "bar",
"foo3" to "baz",
"nullValue" to null
))
bodyMatchers {
jsonPath("$.foo2", byRegex("bar"))
jsonPath("$.foo3", byCommand("executeMe(\$it)"))
jsonPath("$.nullValue", byNull)
}
}
}
您可以使用以下独立的 Maven 命令将合同编译为存根映射: mvn org.springframework.cloud:spring-cloud-contract-maven-plugin:convert |
1.1. 在 Groovy 中约定 DSL
如果您不熟悉 Groovy,请不要担心 - 您可以在 Groovy DSL 文件。
如果您决定用 Groovy 编写合约,如果您还没有使用 Groovy,请不要惊慌 以前。实际上并不需要了解该语言,因为 Contract DSL 只使用 它的一小部分(仅 Literals、Method Calls 和 Closure)。此外,DSL 是静态的 typed,使其无需任何 DSL 本身知识即可程序员可读。
请记住,在 Groovy 合约文件中,您必须提供完整的
限定名称添加到类和静态导入中,例如 .您还可以将导入
类 (),然后调用 .Contract make org.springframework.cloud.spec.Contract.make { … } Contract import org.springframework.cloud.spec.Contract Contract.make { … } |
1.2. Java 中的合约 DSL
要在 Java 中编写合同定义,您需要创建一个类,该类实现单个合同或多个合同的接口。Supplier<Contract>
Supplier<Collection<Contract>>
您还可以在 (例如 ) 下编写 Contract 定义,这样就不必修改项目的 Classpath 。在这种情况下,您必须为 Spring Cloud Contract 插件提供 Contract 定义的新位置。src/test/java
src/test/java/contracts
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<contractsDirectory>src/test/java/contracts</contractsDirectory>
</configuration>
</plugin>
contracts {
contractsDslDir = new File(project.rootDir, "src/test/java/contracts")
}
1.3. 在 Kotlin 中约定 DSL
要开始使用 Kotlin 编写协定,您需要从(新创建的)Kotlin 脚本文件 (.kts) 开始。
就像使用 Java DSL 一样,你可以把你的 Contract 放在你选择的任何目录中。
默认情况下,Maven 和 Gradle 插件将查看该目录。src/test/resources/contracts
您需要将依赖项显式传递给您的项目插件设置。spring-cloud-contract-spec-kotlin
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<!-- some config -->
</configuration>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-spec-kotlin</artifactId>
<version>${spring-cloud-contract.version}</version>
</dependency>
</dependencies>
</plugin>
<dependencies>
<!-- Remember to add this for the DSL support in the IDE and on the consumer side -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-spec-kotlin</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
buildscript {
repositories {
// ...
}
dependencies {
classpath "org.springframework.cloud:spring-cloud-contract-gradle-plugin:${scContractVersion}"
// remember to add this:
classpath "org.springframework.cloud:spring-cloud-contract-spec-kotlin:${scContractVersion}"
}
}
dependencies {
// ...
// Remember to add this for the DSL support in the IDE and on the consumer side
testImplementation "org.springframework.cloud:spring-cloud-contract-spec-kotlin"
}
请记住,在 Kotlin 脚本文件中,您必须为类提供完全限定名称。
通常,您会像这样使用它的 contract 函数:.
您还可以提供对函数 () 的导入,然后调用 .ContractDSL org.springframework.cloud.contract.spec.ContractDsl.contract { … } contract import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract contract { … } |
1.4. YML 中的合约 DSL
要查看 YAML 协定的架构,您可以查看 YML 架构页面。
1.5. 限制
对验证 JSON 数组大小的支持是实验性的。如果需要帮助,
要打开它,请将以下 System Property 的值设置为 : 。默认情况下,此功能设置为 。
您还可以在插件配置中设置该属性。true spring.cloud.contract.verifier.assert.size false assertJsonSize |
由于 JSON 结构可以具有任何形式,因此可能无法对其进行解析
在使用 Groovy DSL 和 中的表示法时正确。那
是您应该使用 Groovy Map 表示法的原因。value(consumer(…), producer(…)) GString |
1.6. 常见的顶级元素
以下部分介绍了最常见的顶级元素:
1.6.1. 描述
您可以将 添加到您的合同中。描述是任意文本。这
下面的代码显示了一个示例:description
org.springframework.cloud.contract.spec.Contract.make {
description('''
given:
An input
when:
Sth happens
then:
Output
''')
}
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)
Contract.make(c -> {
c.description("Some description");
}));
contract {
description = """
given:
An input
when:
Sth happens
then:
Output
"""
}
1.6.2. 名称
您可以为合同提供名称。假设您提供了以下名称:。如果这样做,则自动生成的测试的名称为 。此外,WireMock 存根中的存根名称为 .should register a user
validate_should_register_a_user
should_register_a_user.json
您必须确保名称不包含任何使 生成的测试未编译。另外,请记住,如果您为 多个 Contract,则自动生成的测试无法编译,并且生成的存根 相互覆盖。 |
以下示例演示如何向协定添加名称:
org.springframework.cloud.contract.spec.Contract.make {
name("some_special_name")
}
name: some name
Contract.make(c -> {
c.name("some name");
}));
contract {
name = "some_special_name"
}
1.6.3. 忽略 Contract
如果要忽略合约,可以在
plugin 配置或在 Contract 本身上设置 property 的 intent 来设置属性。以下内容
示例展示了如何做到这一点:ignored
org.springframework.cloud.contract.spec.Contract.make {
ignored()
}
ignored: true
Contract.make(c -> {
c.ignored();
}));
contract {
ignored = true
}
1.6.4. 正在进行的合同
正在进行的合同不会在生产者端生成测试,但允许生成存根。
请谨慎使用此功能,因为它可能会导致误报。您可以生成存根供使用者使用,而无需实际实施! |
如果要设置正在进行的合同,请执行以下操作 示例展示了如何做到这一点:
org.springframework.cloud.contract.spec.Contract.make {
inProgress()
}
inProgress: true
Contract.make(c -> {
c.inProgress();
}));
contract {
inProgress = true
}
您可以设置 Spring Cloud Contract 插件属性的值,以确保当源中至少有一个正在进行的 Contract 时,您的构建将中断。failOnInProgress
1.6.5. 从文件传递值
从 version 开始,您可以从文件中传递值。假设您拥有
项目中的以下资源:1.2.0
└── src
└── test
└── resources
└── contracts
├── readFromFile.groovy
├── request.json
└── response.json
进一步假设您的 Contract 如下:
/*
* Copyright 2013-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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())
}
}
}
request:
method: GET
url: /foo
bodyFromFile: request.json
response:
status: 200
bodyFromFile: response.json
import java.util.Collection;
import java.util.Collections;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
class contract_rest_from_file implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.request(r -> {
r.url("/foo");
r.method(r.GET());
r.body(r.file("request.json"));
});
c.response(r -> {
r.status(r.OK());
r.body(r.file("response.json"));
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
url = url("/1")
method = PUT
headers {
contentType = APPLICATION_JSON
}
body = bodyFromFile("request.json")
}
response {
status = OK
body = bodyFromFile("response.json")
headers {
contentType = APPLICATION_JSON
}
}
}
进一步假设 JSON 文件如下所示:
{
"status": "REQUEST"
}
{
"status": "RESPONSE"
}
当 test 或 stub 生成时,和 文件的内容将传递给正文
请求或响应。文件名需要是位置
相对于合约所在的文件夹。request.json
response.json
如果需要以二进制形式传递文件的内容,
您可以使用编码的 DSL 或 YAML 中的字段中的方法。fileAsBytes
bodyFromFileAsBytes
以下示例演示如何传递二进制文件的内容:
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())
}
}
}
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
import java.util.Collection;
import java.util.Collections;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
class contract_rest_from_pdf implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.request(r -> {
r.url("/1");
r.method(r.PUT());
r.body(r.fileAsBytes("request.pdf"));
r.headers(h -> {
h.contentType(h.applicationOctetStream());
});
});
c.response(r -> {
r.status(r.OK());
r.body(r.fileAsBytes("response.pdf"));
r.headers(h -> {
h.contentType(h.applicationOctetStream());
});
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
url = url("/1")
method = PUT
headers {
contentType = APPLICATION_OCTET_STREAM
}
body = bodyFromFileAsBytes("contracts/request.pdf")
}
response {
status = OK
body = bodyFromFileAsBytes("contracts/response.pdf")
headers {
contentType = APPLICATION_OCTET_STREAM
}
}
}
每当您想要使用二进制有效负载时,都应该使用此方法。 适用于 HTTP 和消息传递。 |
2. HTTP 的契约
Spring Cloud Contract 允许您验证使用 REST 或 HTTP 作为
通讯方式。Spring Cloud Contract 验证,对于与
criteria 中,服务器会提供一个响应,该响应位于
遵守合同的一部分。随后,这些合约被用来
生成 WireMock 存根,对于与提供条件匹配的任何请求,该存根提供
适当的回应。request
response
2.1. HTTP 顶级元素
您可以在合约定义的顶级闭包中调用以下方法:
-
request
:命令的 -
response
:命令的 -
priority
:自选
以下示例演示如何定义 HTTP 请求协定:
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
}
priority: 8
request:
...
response:
...
org.springframework.cloud.contract.spec.Contract.make(c -> {
// Definition of HTTP request part of the contract
// (this can be a valid request or invalid depending
// on type of contract being specified).
c.request(r -> {
r.method(r.GET());
r.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).
c.response(r -> {
r.status(200);
// ...
});
// Contract priority, which can be used for overriding
// contracts (1 is highest). Priority is optional.
c.priority(1);
});
contract {
// 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 = 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 = OK
// ...
}
// Contract priority, which can be used for overriding
// contracts (1 is highest). Priority is optional.
priority = 1
}
如果你想让你的合约具有更高的优先级,
您需要将较小的数字传递给 tag 或 method。例如,带有
值 of 的优先级高于值为 .priority priority 5 priority 10 |
2.2. HTTP 请求
HTTP 协议仅要求在请求中指定方法和 URL。这 在合同的请求定义中,相同的信息是必需的。
以下示例显示了请求的协定:
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
}
}
method: PUT
url: /foo
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// HTTP request method (GET/POST/PUT/DELETE).
r.method("GET");
// Path component of request URL is specified as follows.
r.urlPath("/users");
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
// HTTP request method (GET/POST/PUT/DELETE).
method = method("GET")
// Path component of request URL is specified as follows.
urlPath = path("/users")
}
response {
// ...
status = code(200)
}
}
您可以指定 absolute 而不是 relative ,但使用 is
推荐的方法,因为这样做会使测试独立于主机。url
urlPath
以下示例使用 :url
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
}
}
request:
method: PUT
urlPath: /foo
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
r.method("GET");
// Specifying `url` and `urlPath` in one contract is illegal.
r.url("http://localhost:8888/users");
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
method = GET
// Specifying `url` and `urlPath` in one contract is illegal.
url("http://localhost:8888/users")
}
response {
// ...
status = OK
}
}
request
可能包含查询参数,如下例(使用 )所示:urlPath
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
}
}
request:
...
queryParameters:
a: b
b: c
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// ...
r.method(r.GET());
r.urlPath("/users", u -> {
// 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.
u.queryParameters(q -> {
// If a simple literal is used as value
// default matcher function is used (equalTo)
q.parameter("limit", 100);
// `equalTo` function simply compares passed value
// using identity operator (==).
q.parameter("filter", r.equalTo("email"));
// `containing` function matches strings
// that contains passed substring.
q.parameter("gender",
r.value(r.consumer(r.containing("[mf]")),
r.producer("mf")));
// `matching` function tests parameter
// against passed regular expression.
q.parameter("offset",
r.value(r.consumer(r.matching("[0-9]+")),
r.producer(123)));
// `notMatching` functions tests if parameter
// does not match passed regular expression.
q.parameter("loginStartsWith",
r.value(r.consumer(r.notMatching(".{0,2}")),
r.producer(3)));
});
});
// ...
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
// ...
method = GET
// 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.
urlPath = path("/users") withQueryParameters {
// 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 = code(200)
}
}
request
可以包含其他请求标头,如下例所示:
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
}
}
request:
...
headers:
foo: bar
fooReq: baz
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// ...
r.method(r.GET());
r.url("/foo");
// Each header is added in form `'Header-Name' : 'Header-Value'`.
// there are also some helper methods
r.headers(h -> {
h.header("key", "value");
h.contentType(h.applicationJson());
});
// ...
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
// ...
method = GET
url = url("/foo")
// Each header is added in form `'Header-Name' : 'Header-Value'`.
// there are also some helper variables
headers {
header("key", "value")
contentType = APPLICATION_JSON
}
// ...
}
response {
// ...
status = OK
}
}
request
可能包含其他请求 Cookie,如下例所示:
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
}
}
request:
...
cookies:
foo: bar
fooReq: baz
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// ...
r.method(r.GET());
r.url("/foo");
// Each Cookies is added in form `'Cookie-Key' : 'Cookie-Value'`.
// there are also some helper methods
r.cookies(ck -> {
ck.cookie("key", "value");
ck.cookie("another_key", "another_value");
});
// ...
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
// ...
method = GET
url = 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 = code(200)
}
}
request
可能包含请求正文,如下例所示:
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
}
}
request:
...
body:
foo: bar
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// ...
r.method(r.GET());
r.url("/foo");
// Currently only JSON format of request body is supported.
// Format will be determined from a header or body's content.
r.body("{ \"login\" : \"john\", \"name\": \"John The Contract\" }");
});
c.response(r -> {
// ...
r.status(200);
});
});
contract {
request {
// ...
method = GET
url = url("/foo")
// Currently only JSON format of request body is supported.
// Format will be determined from a header or body's content.
body = body("{ \"login\" : \"john\", \"name\": \"John The Contract\" }")
}
response {
// ...
status = OK
}
}
request
可以包含 multipart 元素。要包含多部分元素,请使用 method/section,如下例所示:multipart
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 OK()
}
}
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(
file: named(
name: value(stub(regex('.+')), test('file')),
content: value(stub(regex('.+')), test([100, 117, 100, 97] as byte[]))
)
)
}
response {
status 200
}
}
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
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.spec.internal.DslProperty;
import org.springframework.cloud.contract.spec.internal.Request;
import org.springframework.cloud.contract.verifier.util.ContractVerifierUtil;
class contract_multipart implements Supplier<Collection<Contract>> {
private static Map<String, DslProperty> namedProps(Request r) {
Map<String, DslProperty> map = new HashMap<>();
// name of the file
map.put("name", r.$(r.c(r.regex(r.nonEmpty())), r.p("filename.csv")));
// content of the file
map.put("content", r.$(r.c(r.regex(r.nonEmpty())), r.p("file content")));
// content type for the part
map.put("contentType", r.$(r.c(r.regex(r.nonEmpty())), r.p("application/json")));
return map;
}
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.request(r -> {
r.method("PUT");
r.url("/multipart");
r.headers(h -> {
h.contentType("multipart/form-data;boundary=AaB03x");
});
r.multipart(ContractVerifierUtil.map()
// key (parameter name), value (parameter value) pair
.entry("formParameter",
r.$(r.c(r.regex("\".+\"")),
r.p("\"formParameterValue\"")))
.entry("someBooleanParameter",
r.$(r.c(r.regex(r.anyBoolean())), r.p("true")))
// a named parameter (e.g. with `file` name) that represents file
// with
// `name` and `content`. You can also call `named("fileName",
// "fileContent")`
.entry("file", r.named(namedProps(r))));
});
c.response(r -> {
r.status(r.OK());
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
method = PUT
url = url("/multipart")
multipart {
field("formParameter", value(consumer(regex("\".+\"")), producer("\"formParameterValue\"")))
field("someBooleanParameter", value(consumer(anyBoolean), producer("true")))
field("file",
named(
// name of the file
value(consumer(regex(nonEmpty)), producer("filename.csv")),
// content of the file
value(consumer(regex(nonEmpty)), producer("file content")),
// content type for the part
value(consumer(regex(nonEmpty)), producer("application/json"))
)
)
}
headers {
contentType = "multipart/form-data;boundary=AaB03x"
}
}
response {
status = OK
}
}
在前面的示例中,我们通过以下两种方式之一定义参数:
-
直接使用 map 表示法,其中值可以是动态属性(如 )。
formParameter: $(consumer(…), producer(…))
-
通过使用允许您设置命名参数的方法。命名参数 可以设置 a 和 .您可以使用带有两个参数的方法 (如 )或使用映射表示法(如 .
named(…)
name
content
named("fileName", "fileContent")
named(name: "fileName", content: "fileContent")
-
multipart 参数在 节中设置。
multipart.params
-
命名参数(给定参数名称的 和 ) 可以在 部分中设置。该部分包含 的 (参数名称)、(文件名)、(文件的内容) 字段。
fileName
fileContent
multipart.named
paramName
fileName
fileContent
-
动态位可以通过 section 进行设置。
matchers.multipart
-
对于参数,请使用 section which can accept 或正则表达式。
params
regex
predefined
-
对于命名参数,请使用第一个 使用 定义参数名称 。然后,您可以将 在 a 或 或正则表达式中对 or 进行参数化。
named
paramName
fileName
fileContent
regex
predefined
-
在前面示例中的 Contract 中,生成的 test 和 stub 如下所示:
// 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);
'''
{
"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--.*"
}, {
"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--.*"
}, {
"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--.*"
} ]
},
"response" : {
"status" : 200,
"transformers" : [ "response-template", "foo-transformer" ]
}
}
'''
2.3. HTTP 响应
响应必须包含 HTTP 状态代码,并且可能包含其他信息。这 下面的代码显示了一个示例:
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()
}
}
response:
...
status: 200
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
// ...
r.method(r.GET());
r.url("/foo");
});
c.response(r -> {
// Status code sent by the server
// in response to request specified above.
r.status(r.OK());
});
});
contract {
request {
// ...
method = GET
url =url("/foo")
}
response {
// Status code sent by the server
// in response to request specified above.
status = OK
}
}
除了 status 之外,响应还可能包含 headers、cookie 和 body,它们是 指定方式与请求中相同(请参阅 HTTP 请求)。
在 Groovy DSL 中,您可以引用这些方法来提供有意义的状态,而不是数字。例如,您可以调用 status 或 .org.springframework.cloud.contract.spec.internal.HttpStatus OK() 200 BAD_REQUEST() 400 |
2.4. 动态属性
合同可以包含一些动态属性:时间戳、ID 等。你不需要 想要强制 Consumer 对他们的 clockstub 进行存根,以始终返回相同的 time 值 以便它与存根匹配。
对于 Groovy DSL,您可以在 Contract 中提供动态部分
有两种方式:直接在正文中传递它们,或者将它们设置在名为 .bodyMatchers
在 2.0.0 之前,这些是使用 和 设置的。
有关更多信息,请参阅迁移指南。testMatchers stubMatchers |
对于 YAML,您只能使用 section.matchers
中的条目必须引用有效负载的现有元素。有关更多信息,请查看此问题。matchers |
2.4.1. Body 内部的动态属性
此部分仅对编码 DSL(Groovy、Java 等)有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。 |
你可以使用 method 设置主体内部的属性,或者如果你使用
Groovy 映射表示法,其中 .以下示例演示如何设置 dynamic
properties 替换为 value 方法:value
$()
value(consumer(...), producer(...))
value(c(...), p(...))
value(stub(...), test(...))
value(client(...), server(...))
$(consumer(...), producer(...))
$(c(...), p(...))
$(stub(...), test(...))
$(client(...), server(...))
这两种方法同样有效。和 方法是方法的别名。后续部分将仔细研究您可以对这些值执行哪些操作。stub
client
consumer
2.4.2. 正则表达式
此部分仅对 Groovy DSL 有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。 |
您可以使用正则表达式在合约 DSL 中写入您的请求。这样做是 当您想要指示应提供给定响应时,特别有用 对于遵循给定模式的请求。此外,在以下情况下,您可以使用正则表达式 需要对测试和服务器端测试使用模式而不是精确值。
确保 regex 匹配序列的整个区域,因为在内部调用了 Pattern.matches()。
例如, 不匹配 ,但匹配。
还有一些其他已知限制。abc
aabc
.abc
以下示例演示如何使用正则表达式编写请求:
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'
}
}
}
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.request(r -> {
r.method("GET");
r.url(r.$(r.consumer(r.regex("\\/[0-9]{2}")), r.producer("/12")));
});
c.response(r -> {
r.status(r.OK());
r.body(ContractVerifierUtil.map().entry("id", r.$(r.anyNumber()))
.entry("surname", r.$(r.consumer("Kowalsky"),
r.producer(r.regex("[a-zA-Z]+")))));
r.headers(h -> {
h.header("Content-Type", "text/plain");
});
});
});
contract {
request {
method = method("GET")
url = url(v(consumer(regex("\\/[0-9]{2}")), producer("/12")))
}
response {
status = OK
body(mapOf(
"id" to v(anyNumber),
"surname" to v(consumer("Kowalsky"), producer(regex("[a-zA-Z]+")))
))
headers {
header("Content-Type", "text/plain")
}
}
}
您也可以使用正则表达式仅提供通信的一侧。如果你 执行此操作,则合约引擎会自动提供生成的匹配 提供的正则表达式。以下代码显示了 Groovy 的示例:
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")
}
}
}
在前面的示例中,通信的另一端具有相应的数据 为 request 和 response 生成。
Spring Cloud Contract 附带了一系列预定义的正则表达式,您可以 在你的 Contract 中使用,如下例所示:
public static RegexProperty onlyAlphaUnicode() {
return new RegexProperty(ONLY_ALPHA_UNICODE).asString();
}
public static RegexProperty alphaNumeric() {
return new RegexProperty(ALPHA_NUMERIC).asString();
}
public static RegexProperty number() {
return new RegexProperty(NUMBER).asDouble();
}
public static RegexProperty positiveInt() {
return new RegexProperty(POSITIVE_INT).asInteger();
}
public static RegexProperty anyBoolean() {
return new RegexProperty(TRUE_OR_FALSE).asBooleanType();
}
public static RegexProperty anInteger() {
return new RegexProperty(INTEGER).asInteger();
}
public static RegexProperty aDouble() {
return new RegexProperty(DOUBLE).asDouble();
}
public static RegexProperty ipAddress() {
return new RegexProperty(IP_ADDRESS).asString();
}
public static RegexProperty hostname() {
return new RegexProperty(HOSTNAME_PATTERN).asString();
}
public static RegexProperty email() {
return new RegexProperty(EMAIL).asString();
}
public static RegexProperty url() {
return new RegexProperty(URL).asString();
}
public static RegexProperty httpsUrl() {
return new RegexProperty(HTTPS_URL).asString();
}
public static RegexProperty uuid() {
return new RegexProperty(UUID).asString();
}
public static RegexProperty isoDate() {
return new RegexProperty(ANY_DATE).asString();
}
public static RegexProperty isoDateTime() {
return new RegexProperty(ANY_DATE_TIME).asString();
}
public static RegexProperty isoTime() {
return new RegexProperty(ANY_TIME).asString();
}
public static RegexProperty iso8601WithOffset() {
return new RegexProperty(ISO8601_WITH_OFFSET).asString();
}
public static RegexProperty nonEmpty() {
return new RegexProperty(NON_EMPTY).asString();
}
public static RegexProperty nonBlank() {
return new RegexProperty(NON_BLANK).asString();
}
在你的 Contract 中,你可以按如下方式使用它(Groovy DSL 的示例):
Contract dslWithOptionalsInString = Contract.make {
priority 1
request {
method POST()
url '/users/password'
headers {
contentType(applicationJson())
}
body(
email: $(consumer(optional(regex(email()))), producer('[email protected]')),
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('[email protected]'))}]"
)
}
}
为了让事情变得更简单,您可以使用一组预定义的对象,这些对象会自动
假设您希望传递正则表达式。
所有这些方法都以 prefix 开头,如下所示: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 {
name "foo"
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'))
])
}
}
contract {
name = "foo"
label = "trigger_event"
input {
triggeredBy = "toString()"
}
outputMessage {
sentTo = sentTo("topic.rateablequote")
body(mapOf(
"alpha" to v(anyAlphaUnicode),
"number" to v(anyNumber),
"anInteger" to v(anyInteger),
"positiveInt" to v(anyPositiveInt),
"aDouble" to v(anyDouble),
"aBoolean" to v(aBoolean),
"ip" to v(anyIpAddress),
"hostname" to v(anyAlphaUnicode),
"email" to v(anyEmail),
"url" to v(anyUrl),
"httpsUrl" to v(anyHttpsUrl),
"uuid" to v(anyUuid),
"date" to v(anyDate),
"dateTime" to v(anyDateTime),
"time" to v(anyTime),
"iso8601WithOffset" to v(anyIso8601WithOffset),
"nonBlankString" to v(anyNonBlankString),
"nonEmptyString" to v(anyNonEmptyString),
"anyOf" to v(anyOf('foo', 'bar'))
))
headers {
header("Content-Type", "text/plain")
}
}
}
2.4.3. 传递可选参数
此部分仅对 Groovy DSL 有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。 |
您可以在合同中提供可选参数。但是,您可以提供 可选参数仅适用于以下各项:
-
请求的 STUB 端
-
Response 的 TEST 端
以下示例说明如何提供可选参数:
org.springframework.cloud.contract.spec.Contract.make {
priority 1
name "optionals"
request {
method 'POST'
url '/users/password'
headers {
contentType(applicationJson())
}
body(
email: $(consumer(optional(regex(email()))), producer('[email protected]')),
callback_url: $(consumer(regex(hostname())), producer('https://partners.com'))
)
}
response {
status 404
headers {
header 'Content-Type': 'application/json'
}
body(
code: value(consumer("123123"), producer(optional("123123")))
)
}
}
org.springframework.cloud.contract.spec.Contract.make(c -> {
c.priority(1);
c.name("optionals");
c.request(r -> {
r.method("POST");
r.url("/users/password");
r.headers(h -> {
h.contentType(h.applicationJson());
});
r.body(ContractVerifierUtil.map()
.entry("email",
r.$(r.consumer(r.optional(r.regex(r.email()))),
r.producer("[email protected]")))
.entry("callback_url", r.$(r.consumer(r.regex(r.hostname())),
r.producer("https://partners.com"))));
});
c.response(r -> {
r.status(404);
r.headers(h -> {
h.header("Content-Type", "application/json");
});
r.body(ContractVerifierUtil.map().entry("code", r.value(
r.consumer("123123"), r.producer(r.optional("123123")))));
});
});
contract { c ->
priority = 1
name = "optionals"
request {
method = POST
url = url("/users/password")
headers {
contentType = APPLICATION_JSON
}
body = body(mapOf(
"email" to v(consumer(optional(regex(email))), producer("[email protected]")),
"callback_url" to v(consumer(regex(hostname)), producer("https://partners.com"))
))
}
response {
status = NOT_FOUND
headers {
header("Content-Type", "application/json")
}
body(mapOf(
"code" to value(consumer("123123"), producer(optional("123123")))
))
}
}
通过使用该方法包装 body 的一部分,您可以创建一个常规的
expression 的表达式,该表达式必须出现 0 次或多次。optional()
如果您使用 Spock,则将根据前面的示例生成以下测试:
"""\
package com.example
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification
import io.restassured.response.ResponseOptions
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*
@SuppressWarnings("rawtypes")
class FooSpec extends Specification {
\tdef validate_optionals() throws Exception {
\t\tgiven:
\t\t\tMockMvcRequestSpecification request = given()
\t\t\t\t\t.header("Content-Type", "application/json")
\t\t\t\t\t.body('''{"email":"[email protected]","callback_url":"https://partners.com"}''')
\t\twhen:
\t\t\tResponseOptions response = given().spec(request)
\t\t\t\t\t.post("/users/password")
\t\tthen:
\t\t\tresponse.statusCode() == 404
\t\t\tresponse.header("Content-Type") == 'application/json'
\t\tand:
\t\t\tDocumentContext parsedJson = JsonPath.parse(response.body.asString())
\t\t\tassertThatJson(parsedJson).field("['code']").matches("(123123)?")
\t}
}
"""
还将生成以下存根:
'''
{
"request" : {
"url" : "/users/password",
"method" : "POST",
"bodyPatterns" : [ {
"matchesJsonPath" : "$[?(@.['email'] =~ /([a-zA-Z0-9._%+-]+@[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
}
'''
2.4.4. 在服务器端执行自定义方法
此部分仅对 Groovy DSL 有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。 |
您可以定义在测试期间在服务器端运行的方法调用。这样的
方法可以添加到配置中定义的类中。这
以下代码显示了测试用例的 Contract 部分的示例:baseClassForTests
method GET()
r.method(r.GET());
method = GET
下面的代码显示了测试用例的基类部分:
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
}
}
不能同时使用 a 和 来执行串联。为
示例,调用潜在客户
不正确的结果。相反,调用 和
确保该方法返回您需要的所有内容。String execute header('Authorization', 'Bearer ' + execute('authToken()')) header('Authorization', execute('authToken()')) authToken() |
从 JSON 中读取的对象类型可以是以下类型之一,具体取决于 JSON 路径:
-
String
:如果您指向 JSON 中的某个值。String
-
JSONArray
:如果您指向 JSON 中的 a。List
-
Map
:如果您指向 JSON 中的 a。Map
-
Number
:如果您指向 、 和 JSON 中的其他数字类型。Integer
Double
-
Boolean
:如果您指向 JSON 中的 a。Boolean
在合同的 request 部分,您可以指定 should take from
方法。body
您必须同时提供使用者端和生产者端。零件
应用于整个身体,而不是身体的某些部分。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);
2.4.5. 从响应中引用 Request
最好的情况是提供固定值,但有时您需要引用 request 的响应。
如果你在 Groovy DSL 中编写契约,你可以使用该方法,它允许
您从 HTTP 请求中引用了一组元素。您可以使用以下内容
选项:fromRequest()
-
fromRequest().url()
:返回请求 URL 和查询参数。 -
fromRequest().query(String key)
:返回具有给定名称的第一个查询参数。 -
fromRequest().query(String key, int index)
:返回第 n 个查询参数,其中包含 名。 -
fromRequest().path()
:返回完整路径。 -
fromRequest().path(int index)
:返回第 n 个 path 元素。 -
fromRequest().header(String key)
:返回具有给定名称的第一个标头。 -
fromRequest().header(String key, int index)
:返回具有给定名称的第 n 个标头。 -
fromRequest().body()
:返回完整的请求正文。 -
fromRequest().body(String jsonPath)
:从请求中返回 匹配 JSON 路径。
如果使用 YAML Contract 定义或 Java Contract 定义,则必须将 Handlebars 表示法与自定义 Spring Cloud Contract 一起使用
功能来实现此目的。在这种情况下,您可以使用以下选项:{{{ }}}
-
{{{ request.url }}}
:返回请求 URL 和查询参数。 -
{{{ request.query.key.[index] }}}
:返回具有给定名称的第 n 个查询参数。 例如,对于 键 ,第一个条目是thing
{{{ request.query.thing.[0] }}}
-
{{{ request.path }}}
:返回完整路径。 -
{{{ request.path.[index] }}}
:返回第 n 个 path 元素。例如 第一个条目是 {{{ request.path.[0] }}}`
-
{{{ request.headers.key }}}
:返回具有给定名称的第一个标头。 -
{{{ request.headers.key.[index] }}}
:返回具有给定名称的第 n 个标头。 -
{{{ request.body }}}
:返回完整的请求正文。 -
{{{ jsonpath this 'your.json.path' }}}
:从请求中返回 匹配 JSON 路径。例如,对于 JSON 路径 ,请使用$.here
{{{ jsonpath this '$.here' }}}
考虑以下合约:
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 OK()
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",
rawUrl: fromRequest().rawUrl(),
rawPath: fromRequest().rawPath(),
rawPathIndex: fromRequest().rawPath(1),
rawParam: fromRequest().rawQuery('foo'),
rawParamIndex: fromRequest().rawQuery('foo', 1),
rawAuthorization: fromRequest().rawHeader('Authorization'),
rawAuthorization2: fromRequest().rawHeader('Authorization', 1),
rawResponseFoo: fromRequest().rawBody('$.foo'),
rawResponseBaz: fromRequest().rawBody('$.baz'),
rawResponseBaz2: "Bla bla ${fromRequest().rawBody('$.foo')} bla bla"
)
}
}
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 OK()
headers {
contentType(applicationJson())
}
body('''
{
"responseFoo": "{{{ jsonPath request.body '$.foo' }}}",
"responseBaz": {{{ jsonPath request.body '$.baz' }}},
"responseBaz2": "Bla bla {{{ jsonPath request.body '$.foo' }}} bla bla"
}
'''.toString())
}
}
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"
package contracts.beer.rest;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.map;
class shouldReturnStatsForAUser implements Supplier<Contract> {
@Override
public Contract get() {
return Contract.make(c -> {
c.request(r -> {
r.method("POST");
r.url("/stats");
r.body(map().entry("name", r.anyAlphaUnicode()));
r.headers(h -> {
h.contentType(h.applicationJson());
});
});
c.response(r -> {
r.status(r.OK());
r.body(map()
.entry("text",
"Dear {{{jsonPath request.body '$.name'}}} thanks for your interested in drinking beer")
.entry("quantity", r.$(r.c(5), r.p(r.anyNumber()))));
r.headers(h -> {
h.contentType(h.applicationJson());
});
});
});
}
}
package contracts.beer.rest
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
method = method("POST")
url = url("/stats")
body(mapOf(
"name" to anyAlphaUnicode
))
headers {
contentType = APPLICATION_JSON
}
}
response {
status = OK
body(mapOf(
"text" to "Don't worry ${fromRequest().body("$.name")} thanks for your interested in drinking beer",
"quantity" to v(c(5), p(anyNumber))
))
headers {
contentType = fromRequest().header(CONTENT_TYPE)
}
}
}
运行 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" ]
}
}
发送请求,例如合同结果部分中显示的请求
在发送以下响应正文时: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"
}
此功能仅适用于大于或等于 WireMock 的版本
更改为 2.5.1。Spring Cloud Contract Verifier 使用 WireMock 的响应转换器。它使用 Handlebars 将 Mustache 模板转换为
适当的值。此外,它还注册了两个帮助程序函数:response-template {{{ }}} |
-
escapejsonbody
:以可嵌入到 JSON 中的格式转义请求正文。 -
jsonpath
:对于给定的参数,在请求正文中查找对象。
2.4.6. Matchers 部分中的动态属性
如果您使用 Pact,以下讨论可能看起来很熟悉。 相当多的用户习惯于在 body 和设置 合同的动态部分。
您可以使用该部分有两个原因:bodyMatchers
-
定义应以存根结尾的动态值。 您可以在 Contract 的 或 部分 中设置它。
request
inputMessage
-
验证您的测试结果。 此部分位于 或 合同。
response
outputMessage
目前, Spring Cloud Contract Verifier 仅支持基于 JSON 路径的匹配器,其中 以下匹配可能性:
编码 DSL
-
对于 stub (在使用者端的测试中):
-
byEquality()
:从提供的 JSON 路径中的使用者请求中获取的值必须为 等于合同中提供的值。 -
byRegex(…)
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配正则表达式。您还可以传递预期匹配值的类型(例如, , , 等)。asString()
asLong()
-
byDate()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO 日期值的正则表达式。 -
byTimestamp()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO DateTime 值的正则表达式。 -
byTime()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO Time 值的正则表达式。
-
-
对于验证(在 Producer 端生成的测试中):
-
byEquality()
:从提供的 JSON 路径中的生产者响应中获取的值必须为 等于 Contract 中提供的值。 -
byRegex(…)
:从提供的 JSON 路径中的生产者响应中获取的值必须 匹配正则表达式。 -
byDate()
:从提供的 JSON 路径中的生产者响应中获取的值必须匹配 ISO Date 值的正则表达式。 -
byTimestamp()
:从提供的 JSON 路径中的生产者响应中获取的值必须 匹配 ISO DateTime 值的正则表达式。 -
byTime()
:从提供的 JSON 路径中的生产者响应中获取的值必须匹配 ISO Time 值的正则表达式。 -
byType()
:从提供的 JSON 路径中的生产者响应中获取的值需要为 与协定中响应正文中定义的类型相同。 可以采用一个闭包,您可以在其中设置 和 。对于 请求端,你应该使用 closure 来断言集合的大小。 这样,您就可以断言扁平化集合的大小。要检查 unflattened 集合中,请使用 .byType
minOccurrence
maxOccurrence
byCommand(…)
testMatcher
-
byCommand(…)
:从提供的 JSON 路径中的生产者响应中获取的值为 作为输入传递给您提供的自定义方法。例如,导致调用一个方法,其中与 JSON 路径被传递。从 JSON 中读取的对象类型可以是 ,具体取决于 JSON 路径:byCommand('thing($it)')
thing
-
String
:如果指向某个值。String
-
JSONArray
:如果指向 .List
-
Map
:如果指向 .Map
-
Number
:如果指向 、 或其他类型的数字。Integer
Double
-
Boolean
:如果指向 .Boolean
-
-
byNull()
:从提供的 JSON 路径中的响应中获取的值必须为 null。
-
YAML
有关 类型含义。 |
对于 YAML,匹配器的结构类似于以下示例:
- path: $.thing1
type: by_regex
value: thing2
regexType: as_string
或者,如果要使用预定义的正则表达式之一,可以使用类似于以下示例的内容:[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: $.thing1
type: by_regex
predefined: only_alpha_unicode
以下列表显示了允许的值列表:type
-
为:
stubMatchers
-
by_equality
-
by_regex
-
by_date
-
by_timestamp
-
by_time
-
by_type
-
接受两个附加字段 ( 和 )。
minOccurrence
maxOccurrence
-
-
-
为:
testMatchers
-
by_equality
-
by_regex
-
by_date
-
by_timestamp
-
by_time
-
by_type
-
接受两个附加字段 ( 和 )。
minOccurrence
maxOccurrence
-
-
by_command
-
by_null
-
您还可以定义正则表达式在字段中对应的类型。以下列表显示了允许的正则表达式类型:regexType
-
as_integer
-
as_double
-
as_float
-
as_long
-
as_short
-
as_boolean
-
as_string
请考虑以下示例:
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}'))))
}
}
}
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: '[email protected]'
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
在前面的示例中,您可以在各节中看到协定的动态部分。对于请求部分,您可以看到,对于除 之外的所有字段,存根应
contain 的 intent 是对于 ,将进行验证
与不使用 matchers 的方式相同。在这种情况下,测试会执行
相等性检查。matchers
valueWithoutAMatcher
valueWithoutAMatcher
对于本节中的响应端,我们在
类似的方式。唯一的区别是 matchers 也存在。这
验证程序引擎检查四个字段,以验证来自测试的响应
的值 JSON 路径与给定字段匹配,与该值的类型相同
定义,并通过以下检查(基于正在调用的方法):bodyMatchers
byType
-
对于 ,引擎检查类型是否相同。
$.valueWithTypeMatch
-
对于 ,引擎会检查类型并断言大小是否更大 小于或等于最小出现次数。
$.valueWithMin
-
对于 ,引擎会检查类型并断言大小是否为 小于或等于最大出现次数。
$.valueWithMax
-
对于 ,引擎会检查类型并断言大小是否为 介于最小出现次数和最大出现次数之间。
$.valueWithMinMax
生成的测试类似于以下示例(请注意,部分
将自动生成的断言和断言与 matchers 分开):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\",\"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");
请注意,对于该方法,该示例调用 .此方法必须在测试基类中定义,或者是
静态导入到您的测试中。请注意,该调用已转换为 .这意味着发动机需要
方法名称,并将正确的 JSON 路径作为参数传递给它。byCommand assertThatValueIsANumber byCommand assertThatValueIsANumber(parsedJson.read("$.duck")); |
生成的 WireMock 存根位于以下示例中:
'''
{
"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" : "{\\"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],\\"valueWithOccurrence\\":[1,2,3,4]}",
"headers" : {
"Content-Type" : "application/json"
},
"transformers" : [ "response-template" ]
}
}
'''
如果您使用 ,则请求和响应中具有 JSON 路径的地址将从断言中删除的部分。在
验证集合时,您必须为
收集。matcher 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('.+'))
}
}
}
前面的代码导致创建以下测试(代码块仅显示 assertion 部分):
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(".+")
如您所见,该断言格式不正确。只有数组的第一个元素得到
断言。为了解决这个问题,你应该将断言应用于整个集合,并使用该方法断言它。$.events
byCommand(…)
2.5. 异步支持
如果您在服务器端使用异步通信(您的控制器是
return 、 、 等),那么,在您的 Contract 中,您必须
在 节 中提供方法。下面的代码显示了一个示例:Callable
DeferredResult
async()
response
org.springframework.cloud.contract.spec.Contract.make {
request {
method GET()
url '/get'
}
response {
status OK()
body 'Passed'
async()
}
}
response:
async: true
class contract implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.request(r -> {
// ...
});
c.response(r -> {
r.async();
// ...
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
// ...
}
response {
async = true
// ...
}
}
您还可以使用 method 或 property 向存根添加延迟。
以下示例显示了如何执行此操作:fixedDelayMilliseconds
org.springframework.cloud.contract.spec.Contract.make {
request {
method GET()
url '/get'
}
response {
status 200
body 'Passed'
fixedDelayMilliseconds 1000
}
}
response:
fixedDelayMilliseconds: 1000
class contract implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {
c.request(r -> {
// ...
});
c.response(r -> {
r.fixedDelayMilliseconds(1000);
// ...
});
}));
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
// ...
}
response {
delay = fixedMilliseconds(1000)
// ...
}
}
2.6. XML HTTP 支持
对于 HTTP 协定,我们还支持在请求和响应正文中使用 XML。
XML 主体必须在元素中传递
作为 a 或 .此外,还可以提供身体匹配器
请求和响应。应使用方法代替方法,并将 desired 作为第一个参数提供
和适当的作为第二个。支持除 之外的所有 body matcher。body
String
GString
jsonPath(…)
org.springframework.cloud.contract.spec.internal.BodyMatchers.xPath
xPath
MatchingType
byType()
下面的示例展示了一个 Groovy DSL 契约,响应正文中带有 XML:
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('equals($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())
}
}
}
include::/tmp/releaser-1625584814123-0/spring-cloud-contract/spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_xml.yml
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
class contract_xml implements Supplier<Contract> {
@Override
public Contract get() {
return Contract.make(c -> {
c.request(r -> {
r.method(r.GET());
r.urlPath("/get");
r.headers(h -> {
h.contentType(h.applicationXml());
});
});
c.response(r -> {
r.status(r.OK());
r.headers(h -> {
h.contentType(h.applicationXml());
});
r.body("<test>\n" + "<duck type='xtype'>123</duck>\n"
+ "<alpha>abc</alpha>\n" + "<list>\n" + "<elem>abc</elem>\n"
+ "<elem>def</elem>\n" + "<elem>ghi</elem>\n" + "</list>\n"
+ "<number>123</number>\n" + "<aBoolean>true</aBoolean>\n"
+ "<date>2017-01-01</date>\n"
+ "<dateTime>2017-01-01T01:23:45</dateTime>\n"
+ "<time>01:02:34</time>\n"
+ "<valueWithoutAMatcher>foo</valueWithoutAMatcher>\n"
+ "<key><complex>foo</complex></key>\n" + "</test>");
r.bodyMatchers(m -> {
m.xPath("/test/duck/text()", m.byRegex("[0-9]{3}"));
m.xPath("/test/duck/text()", m.byCommand("equals($it)"));
m.xPath("/test/duck/xxx", m.byNull());
m.xPath("/test/duck/text()", m.byEquality());
m.xPath("/test/alpha/text()", m.byRegex(r.onlyAlphaUnicode()));
m.xPath("/test/alpha/text()", m.byEquality());
m.xPath("/test/number/text()", m.byRegex(r.number()));
m.xPath("/test/date/text()", m.byDate());
m.xPath("/test/dateTime/text()", m.byTimestamp());
m.xPath("/test/time/text()", m.byTime());
m.xPath("/test/*/complex/text()", m.byEquality());
m.xPath("/test/duck/@type", m.byEquality());
});
});
});
};
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
request {
method = GET
urlPath = path("/get")
headers {
contentType = APPLICATION_XML
}
}
response {
status = OK
headers {
contentType =APPLICATION_XML
}
body = body("<test>\n" + "<duck type='xtype'>123</duck>\n"
+ "<alpha>abc</alpha>\n" + "<list>\n" + "<elem>abc</elem>\n"
+ "<elem>def</elem>\n" + "<elem>ghi</elem>\n" + "</list>\n"
+ "<number>123</number>\n" + "<aBoolean>true</aBoolean>\n"
+ "<date>2017-01-01</date>\n"
+ "<dateTime>2017-01-01T01:23:45</dateTime>\n"
+ "<time>01:02:34</time>\n"
+ "<valueWithoutAMatcher>foo</valueWithoutAMatcher>\n"
+ "<key><complex>foo</complex></key>\n" + "</test>")
bodyMatchers {
xPath("/test/duck/text()", byRegex("[0-9]{3}"))
xPath("/test/duck/text()", byCommand("equals(\$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 测试:
@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");
}
2.7. 一个文件中的多个合约
您可以在一个文件中定义多个合同。这样的合约可能类似于 以下示例:
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()
}
}
]
---
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
class contract implements Supplier<Collection<Contract>> {
@Override
public Collection<Contract> get() {
return Arrays.asList(
Contract.make(c -> {
c.name("should post a user");
// ...
}), Contract.make(c -> {
// ...
}), Contract.make(c -> {
// ...
})
);
}
}
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
arrayOf(
contract {
name("should post a user")
// ...
},
contract {
// ...
},
contract {
// ...
}
}
在前面的示例中,一个 Contract 具有字段,而另一个 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);
}
}
请注意,对于具有字段的 Contract,生成的测试方法名为 。没有该字段的名为 。它对应于文件名和
列表中合约的索引。name
validate_should_post_a_user
name
validate_withList_1
WithList.groovy
生成的存根如以下示例所示:
should post a user.json
1_WithList.json
第一个文件从合约中获取参数。第二个
获取了以索引为前缀的合约文件 () 的名称(在此
的情况下,合约在文件的合约列表中有一个索引 )。name
WithList.groovy
1
为您的合约命名要好得多,因为这样做会使 您的测试更有意义。 |
2.8. 有状态合约
有状态合同(也称为场景)是应该读取的合同定义 挨次。这在以下情况下可能很有用:
-
您希望以精确定义的顺序执行合约,因为您使用了 Spring 用于测试有状态应用程序的 Cloud Contract
我们真的不鼓励你这样做,因为 Contract 测试应该是无状态的。 |
-
您希望同一终端节点为同一请求返回不同的结果。
要创建有状态合约(或场景),您需要 在创建合同时使用正确的命名约定。约定 要求包含订单号,后跟下划线。这无论如何都有效 了解您是使用 YAML 还是 Groovy。下面的清单显示了一个示例:
my_contracts_dir\
scenario1\
1_login.groovy
2_showCart.groovy
3_logout.groovy
这样的树会导致 Spring Cloud Contract Verifier 生成 WireMock 的场景,其中包含
name of 和以下三个步骤:scenario1
-
登录,标记为指向...
Started
-
showCart 中,标记为指向...
Step1
-
注销,标记为 (这将关闭方案)。
Step2
您可以在 https://wiremock.org/docs/stateful-behaviour/ 中找到有关 WireMock 方案的更多详细信息。
3. 集成
3.1. JAX-RS 系列
Spring Cloud Contract 支持 JAX-RS 2 客户端 API。基类需要
定义和服务器初始化。唯一的选项
测试 JAX-RS API 是为了启动 Web 服务器。此外,带有正文的请求需要有一个
content 类型。否则,将使用默认值。protected WebTarget webTarget
application/octet-stream
要使用 JAX-RS 模式,请使用以下设置:
testMode = 'JAXRSCLIENT'
以下示例显示了生成的测试 API:
"""\
package com.example;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static javax.ws.rs.client.Entity.*;
@SuppressWarnings("rawtypes")
public class FooTest {
\tWebTarget webTarget;
\t@Test
\tpublic void validate_() throws Exception {
\t\t// when:
\t\t\tResponse response = webTarget
\t\t\t\t\t\t\t.path("/users")
\t\t\t\t\t\t\t.queryParam("limit", "10")
\t\t\t\t\t\t\t.queryParam("offset", "20")
\t\t\t\t\t\t\t.queryParam("filter", "email")
\t\t\t\t\t\t\t.queryParam("sort", "name")
\t\t\t\t\t\t\t.queryParam("search", "55")
\t\t\t\t\t\t\t.queryParam("age", "99")
\t\t\t\t\t\t\t.queryParam("name", "Denis.Stepanov")
\t\t\t\t\t\t\t.queryParam("email", "[email protected]")
\t\t\t\t\t\t\t.request()
\t\t\t\t\t\t\t.build("GET")
\t\t\t\t\t\t\t.invoke();
\t\t\tString responseAsString = response.readEntity(String.class);
\t\t// then:
\t\t\tassertThat(response.getStatus()).isEqualTo(200);
\t\t// and:
\t\t\tDocumentContext parsedJson = JsonPath.parse(responseAsString);
\t\t\tassertThatJson(parsedJson).field("['property1']").isEqualTo("a");
\t}
}
"""
3.2. 使用 WebTestClient 的 WebFlux
您可以使用 WebTestClient 来使用 WebFlux。以下清单显示了如何 配置 WebTestClient 作为测试模式:
<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>
contracts {
testMode = 'WEBTESTCLIENT'
}
以下示例演示如何设置 WebTestClient 基类和 RestAssure 对于 WebFlux:
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));
}
}
}
该模式比该模式更快。WebTestClient EXPLICIT |
3.3. 具有显式模式的 WebFlux
您还可以在生成的测试中将 WebFlux 与 explicit 模式一起使用 以使用 WebFlux。以下示例演示如何使用显式模式进行配置:
<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'
}
以下示例显示了如何为 Web Flux 设置基类和 RestAssured:
@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());
}
}
}
3.4. 使用上下文路径
Spring Cloud Contract 支持上下文路径。
完全支持上下文路径所需的唯一更改是
producer 端。此外,自动生成的测试必须使用显式模式。消费者
侧面保持不变。为了使生成的测试通过,您必须使用 explicit
模式。以下示例显示如何将测试模式设置为 : Maven 系列
Gradle
|
这样,您可以生成一个不使用 MockMvc 的测试。这意味着您生成 real 请求,并且您需要设置生成的测试的基类才能在真实的 插座。
考虑以下合约:
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET'
url '/my-context-path/url'
}
response {
status OK()
}
}
以下示例演示如何设置基类和 RestAssured:
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
-
您的合同反映您有一个上下文路径。您生成的存根还具有 该信息(例如,在存根中,您必须调用 )。
/my-context-path/url
3.5. 使用 REST 文档
您可以使用 Spring REST Docs 生成
文档(例如,以 Asciidoc 格式)用于 Spring MockMvc 或 RestAssure 的 HTTP API。在为 API 生成文档的同时,您还可以
使用 Spring Cloud Contract WireMock 生成 WireMock 存根。为此,请编写
普通的 REST Docs 测试用例,并且曾经有 stub 是
在 REST Docs 输出目录中自动生成。WebTestClient
@AutoConfigureRestDocs
以下示例使用 :MockMvc
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureRestDocs(outputDir = "target/snippets")
@AutoConfigureMockMvc
public class ApplicationTests {
@Autowired
private MockMvc mockMvc;
@Test
public void contextLoads() throws Exception {
mockMvc.perform(get("/resource"))
.andExpect(content().string("Hello World"))
.andDo(document("resource"));
}
}
此测试在 处生成 WireMock 存根。它匹配
所有请求都发送到该路径。相同的示例(使用
用于测试 Spring WebFlux 应用程序)将如下所示:target/snippets/stubs/resource.json
GET
/resource
WebTestClient
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureRestDocs(outputDir = "target/snippets")
@AutoConfigureWebTestClient
public class ApplicationTests {
@Autowired
private WebTestClient client;
@Test
public void contextLoads() throws Exception {
client.get().uri("/resource").exchange()
.expectBody(String.class).isEqualTo("Hello World")
.consumeWith(document("resource"));
}
}
无需任何其他配置,这些测试将创建一个带有请求匹配器的存根
对于 HTTP 方法以及除 和 之外的所有标头。要匹配
request 更精确地(例如,为了匹配 POST 或 PUT 的正文),我们需要
显式创建请求匹配器。这样做有两个效果:host
content-length
-
创建仅以指定方式匹配的存根。
-
断言测试用例中的请求也匹配相同的条件。
此功能的主要入口点是 ,可以使用
作为便捷方法的替代方法,如下所示
示例显示:WireMockRestDocs.verify()
document()
import static org.springframework.cloud.contract.wiremock.restdocs.WireMockRestDocs.verify;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureRestDocs(outputDir = "target/snippets")
@AutoConfigureMockMvc
public class ApplicationTests {
@Autowired
private MockMvc mockMvc;
@Test
public void contextLoads() throws Exception {
mockMvc.perform(post("/resource")
.content("{\"id\":\"123456\",\"message\":\"Hello World\"}"))
.andExpect(status().isOk())
.andDo(verify().jsonPath("$.id")
.andDo(document("resource"));
}
}
前面的 Contract 指定任何带有字段的有效 POST 都会收到响应
在此测试中定义。您可以将调用链接在一起以添加其他
匹配器。如果不熟悉 JSON 路径,则 JayWay
文档可以帮助您快速上手。此测试的版本
具有插入到同一位置的类似 static 辅助对象。id
.jsonPath()
WebTestClient
verify()
除了 and 方便方法,您还可以使用
WireMock API 验证请求是否与创建的存根匹配,因为
以下示例显示:jsonPath
contentType
@Test
public void contextLoads() throws Exception {
mockMvc.perform(post("/resource")
.content("{\"id\":\"123456\",\"message\":\"Hello World\"}"))
.andExpect(status().isOk())
.andDo(verify()
.wiremock(WireMock.post(
urlPathEquals("/resource"))
.withRequestBody(matchingJsonPath("$.id"))
.andDo(document("post-resource"));
}
WireMock API 非常丰富。您可以通过以下方式匹配标头、查询参数和请求正文 regex 以及 JSON 路径。您可以使用这些功能创建具有更宽 参数范围。前面的示例生成类似于以下示例的存根:
{
"request" : {
"url" : "/resource",
"method" : "POST",
"bodyPatterns" : [ {
"matchesJsonPath" : "$.id"
}]
},
"response" : {
"status" : 200,
"body" : "Hello World",
"headers" : {
"X-Application-Context" : "application:-1",
"Content-Type" : "text/plain"
}
}
}
您可以使用 method 或 and 方法创建请求匹配器,但不能同时使用这两种方法。wiremock() jsonPath() contentType() |
在消费者端,您可以在本节前面进行生成的
available (例如,通过将存根发布为 JAR)。之后,您可以在
多种不同的方法,包括使用 ,如本文前面所述
公文。resource.json
@AutoConfigureWireMock(stubs="classpath:resource.json")
3.5.1. 使用 REST Docs 生成合约
您还可以使用 Spring REST 生成 Spring Cloud Contract DSL 文件和文档 文档。如果与 Spring Cloud WireMock 结合使用执行此操作,则会同时获得两个 Contract 和存根。
为什么要使用此功能?社区中的一些人提出了问题 关于他们希望转向基于 DSL 的合约定义的情况, 但是他们已经有很多 Spring MVC 测试了。使用此功能可让您生成 您稍后可以修改并移动到文件夹(在 configuration),以便插件找到它们。
您可能想知道为什么 WireMock 模块中有此功能。功能 存在,因为生成 Contract 和 Stub 都是有意义的。 |
请考虑以下测试:
this.mockMvc
.perform(post("/foo").accept(MediaType.APPLICATION_PDF)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"foo\": 23, \"bar\" : \"baz\" }"))
.andExpect(status().isOk()).andExpect(content().string("bar"))
// first WireMock
.andDo(WireMockRestDocs.verify().jsonPath("$[?(@.foo >= 20)]")
.jsonPath("$[?(@.bar in ['baz','bazz','bazzz'])]")
.contentType(MediaType.valueOf("application/json")))
// then Contract DSL documentation
.andDo(document("index", SpringCloudContractRestDocs.dslContract()));
前面的测试会创建上一节中介绍的存根,并生成 合同和文档文件。
调用 Contract 并可能类似于以下示例:index.groovy
import org.springframework.cloud.contract.spec.Contract
Contract.make {
request {
method 'POST'
url '/foo'
body('''
{"foo": 23 }
''')
headers {
header('''Accept''', '''application/json''')
header('''Content-Type''', '''application/json''')
}
}
response {
status OK()
body('''
bar
''')
headers {
header('''Content-Type''', '''application/json;charset=UTF-8''')
header('''Content-Length''', '''3''')
}
bodyMatchers {
jsonPath('$[?(@.foo >= 20)]', byType())
}
}
}
生成的文档(在本例中为 Asciidoc 格式)包含一个格式化的
合同。此文件的位置将为 。index/dsl-contract.adoc
4. 消息
Spring Cloud Contract 允许您验证使用消息传递作为 通讯方式。本文档中展示的所有集成都可以与 Spring 一起使用。 但你也可以创建自己的一个并使用它。
4.1. 消息传递 DSL 顶级元素
用于消息传递的 DSL 看起来与专注于 HTTP 的 DSL 略有不同。这 以下部分解释了差异:
4.1.1. 由方法触发的输出
输出消息可以通过调用方法(例如,当合约是
started 并发送了一条消息),如以下示例所示:Scheduler
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')
}
}
}
# 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
在前面的示例案例中,如果执行了调用的方法,则会发送输出消息。在消息发布者方面,我们生成一个
test 调用该方法来触发消息。在消费者方面,您可以使用
触发消息。output
bookReturnedTriggered
some_label
4.1.2. 消息触发的输出
可以通过接收消息来触发输出消息,如下所示 例:
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')
}
}
}
# 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
在前面的示例中,如果正确的消息是
在目标上收到。在消息发布者方面,引擎
生成一个测试,将输入消息发送到定义的目标。在
Consumer 端,您可以向 Input 目标发送消息或使用标签
(在示例中) 触发消息。output
input
some_label
4.1.3. 消费者/生产者
此部分仅对 Groovy DSL 有效。 |
在 HTTP 中,你有一个 // 表示法的概念。您还可以
在消息传递中使用这些范例。此外,Spring Cloud Contract Verifier 还
提供了 and 方法,如以下示例所示
(注意,你可以使用 or 方法提供 and 部分):client
stub and `server
test
consumer
producer
$
value
consumer
producer
Contract.make {
name "foo"
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'
])
}
}
4.1.4. 普通
在 or 部分中,您可以使用名称
的 (例如,) 中,您在
基类或静态导入。Spring Cloud Contract 运行该方法
在生成的测试中。input
outputMessage
assertThat
method
assertThatMessageIsOnTheQueue()
4.2. 集成
您可以使用以下四种集成配置之一:
-
阿帕奇骆驼
-
Spring 集成
-
Spring Cloud 流
-
Spring AMQP
-
Spring JMS(需要嵌入式代理)
-
Spring Kafka(需要嵌入式代理)
由于我们使用 Spring Boot,如果您已将这些库之一添加到 Classpath 中,则所有 消息收发配置是自动设置的。
记得把你的
生成的测试。否则,Spring Cloud Contract 的消息传递部分不会
工作。@AutoConfigureMessageVerifier |
如果要使用 Spring Cloud Stream,请记得在 上添加依赖,如下所示: Maven 系列
Gradle
|
4.2.1. 手动集成测试
测试使用的主要界面是 .
它定义了如何发送和接收消息。您可以创建自己的实现
实现相同的目标。org.springframework.cloud.contract.verifier.messaging.MessageVerifier
在测试中,您可以注入 a 来发送和接收
遵循 Contract 的消息。然后添加到您的测试中。
以下示例显示了如何执行此操作:ContractVerifierMessageExchange
@AutoConfigureMessageVerifier
@RunWith(SpringTestRunner.class)
@SpringBootTest
@AutoConfigureMessageVerifier
public static class MessagingContractTests {
@Autowired
private MessageVerifier verifier;
...
}
如果您的测试也需要存根,则包括
消息传送配置,因此您只需要一个注释。@AutoConfigureStubRunner |
4.3. 生产者端消息传递测试生成
在 DSL 中包含 or 部分会导致创建测试
在出版商方面。默认情况下,将创建 JUnit 4 测试。但是,还有一个
可以创建 JUnit 5、TestNG 或 Spock 测试。input
outputMessage
我们应该考虑三种主要情况:
-
方案 1:没有生成输出消息的输入消息。输出 message 由应用程序内的组件(例如,调度程序)触发。
-
场景 2:输入消息触发输出消息。
-
场景 3:输入消息被消费,没有输出消息。
传递给的目标或可以具有不同的
不同消息传递实现的含义。对于 Stream 和 Integration,它是
首先解析为 A of a 通道。然后,如果没有这样的 API,则将其解析为 channel name。对于 Camel,这是一个特定的组件(例如,)。messageFrom sentTo destination destination jms |
4.3.1. 场景 1:无输入消息
考虑以下合约:
def contractDsl = Contract.make {
name "foo"
label 'some_label'
input {
triggeredBy('bookReturnedTriggered()')
}
outputMessage {
sentTo('activemq:output')
body('''{ "bookName" : "foo" }''')
headers {
header('BOOK-NAME', 'foo')
messagingContentType(applicationJson())
}
}
}
label: some_label
input:
triggeredBy: bookReturnedTriggered
outputMessage:
sentTo: activemq:output
body:
bookName: foo
headers:
BOOK-NAME: foo
contentType: application/json
对于前面的示例,将创建以下测试:
'''\
package com.example;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.inject.Inject;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes;
@SuppressWarnings("rawtypes")
public class FooTest {
\t@Inject ContractVerifierMessaging contractVerifierMessaging;
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
\t@Test
\tpublic void validate_foo() throws Exception {
\t\t// when:
\t\t\tbookReturnedTriggered();
\t\t// then:
\t\t\tContractVerifierMessage response = contractVerifierMessaging.receive("activemq:output");
\t\t\tassertThat(response).isNotNull();
\t\t// and:
\t\t\tassertThat(response.getHeader("BOOK-NAME")).isNotNull();
\t\t\tassertThat(response.getHeader("BOOK-NAME").toString()).isEqualTo("foo");
\t\t\tassertThat(response.getHeader("contentType")).isNotNull();
\t\t\tassertThat(response.getHeader("contentType").toString()).isEqualTo("application/json");
\t\t// and:
\t\t\tDocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
\t\t\tassertThatJson(parsedJson).field("['bookName']").isEqualTo("foo");
\t}
}
'''
'''\
package com.example
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import javax.inject.Inject
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes
@SuppressWarnings("rawtypes")
class FooSpec extends Specification {
\t@Inject ContractVerifierMessaging contractVerifierMessaging
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper
\tdef validate_foo() throws Exception {
\t\twhen:
\t\t\tbookReturnedTriggered()
\t\tthen:
\t\t\tContractVerifierMessage response = contractVerifierMessaging.receive("activemq:output")
\t\t\tresponse != null
\t\tand:
\t\t\tresponse.getHeader("BOOK-NAME") != null
\t\t\tresponse.getHeader("BOOK-NAME").toString() == 'foo'
\t\t\tresponse.getHeader("contentType") != null
\t\t\tresponse.getHeader("contentType").toString() == 'application/json'
\t\tand:
\t\t\tDocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()))
\t\t\tassertThatJson(parsedJson).field("['bookName']").isEqualTo("foo")
\t}
}
'''
4.3.2. 场景 2:由输入触发的输出
考虑以下合约:
def contractDsl = Contract.make {
name "foo"
label 'some_label'
input {
messageFrom('jms:input')
messageBody([
bookName: 'foo'
])
messageHeaders {
header('sample', 'header')
}
}
outputMessage {
sentTo('jms:output')
body([
bookName: 'foo'
])
headers {
header('BOOK-NAME', 'foo')
}
}
}
label: some_label
input:
messageFrom: jms:input
messageBody:
bookName: 'foo'
messageHeaders:
sample: header
outputMessage:
sentTo: jms:output
body:
bookName: foo
headers:
BOOK-NAME: foo
对于前面的 Contract,将创建以下测试:
'''\
package com.example;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.inject.Inject;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes;
@SuppressWarnings("rawtypes")
public class FooTest {
\t@Inject ContractVerifierMessaging contractVerifierMessaging;
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
\t@Test
\tpublic void validate_foo() throws Exception {
\t\t// given:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t"{\\"bookName\\":\\"foo\\"}"
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t);
\t\t// when:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:input");
\t\t// then:
\t\t\tContractVerifierMessage response = contractVerifierMessaging.receive("jms:output");
\t\t\tassertThat(response).isNotNull();
\t\t// and:
\t\t\tassertThat(response.getHeader("BOOK-NAME")).isNotNull();
\t\t\tassertThat(response.getHeader("BOOK-NAME").toString()).isEqualTo("foo");
\t\t// and:
\t\t\tDocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
\t\t\tassertThatJson(parsedJson).field("['bookName']").isEqualTo("foo");
\t}
}
'''
"""\
package com.example
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import javax.inject.Inject
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes
@SuppressWarnings("rawtypes")
class FooSpec extends Specification {
\t@Inject ContractVerifierMessaging contractVerifierMessaging
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper
\tdef validate_foo() throws Exception {
\t\tgiven:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t'''{"bookName":"foo"}'''
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t)
\t\twhen:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:input")
\t\tthen:
\t\t\tContractVerifierMessage response = contractVerifierMessaging.receive("jms:output")
\t\t\tresponse != null
\t\tand:
\t\t\tresponse.getHeader("BOOK-NAME") != null
\t\t\tresponse.getHeader("BOOK-NAME").toString() == 'foo'
\t\tand:
\t\t\tDocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()))
\t\t\tassertThatJson(parsedJson).field("['bookName']").isEqualTo("foo")
\t}
}
"""
4.3.3. 场景 3:无输出消息
考虑以下合约:
def contractDsl = Contract.make {
name "foo"
label 'some_label'
input {
messageFrom('jms:delete')
messageBody([
bookName: 'foo'
])
messageHeaders {
header('sample', 'header')
}
assertThat('bookWasDeleted()')
}
}
label: some_label
input:
messageFrom: jms:delete
messageBody:
bookName: 'foo'
messageHeaders:
sample: header
assertThat: bookWasDeleted()
对于前面的 Contract,将创建以下测试:
"""\
package com.example;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.inject.Inject;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes;
@SuppressWarnings("rawtypes")
public class FooTest {
\t@Inject ContractVerifierMessaging contractVerifierMessaging;
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
\t@Test
\tpublic void validate_foo() throws Exception {
\t\t// given:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t"{\\"bookName\\":\\"foo\\"}"
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t);
\t\t// when:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:delete");
\t\t\tbookWasDeleted();
\t}
}
"""
"""\
package com.example
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import javax.inject.Inject
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes
@SuppressWarnings("rawtypes")
class FooSpec extends Specification {
\t@Inject ContractVerifierMessaging contractVerifierMessaging
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper
\tdef validate_foo() throws Exception {
\t\tgiven:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t'''{"bookName":"foo"}'''
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t)
\t\twhen:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:delete")
\t\t\tbookWasDeleted()
\t\tthen:
\t\t\tnoExceptionThrown()
\t}
}
"""
4.4. Consumer Stub 生成
与 HTTP 部分不同,在消息传递中,我们需要在 JAR 中发布合约定义,并且 一个存根。然后在使用者端对其进行解析,并创建适当的存根路由。
如果您在 Classpath 上有多个框架,则 Stub Runner 需要
定义应该使用哪一个。假设您拥有 AMQP、Spring Cloud Stream 和 Spring Integration
在 Classpath 上,并且您希望使用 Spring AMQP。然后,您需要设置 和 。
这样,唯一剩下的框架就是 Spring AMQP。stubrunner.stream.enabled=false stubrunner.integration.enabled=false |
4.4.1. 存根触发
要触发消息,请使用该接口,如下例所示:StubTrigger
package org.springframework.cloud.contract.stubrunner;
import java.util.Collection;
import java.util.Map;
/**
* Contract for triggering stub messages.
*
* @author Marcin Grzejszczak
*/
public interface StubTrigger {
/**
* Triggers an event by a given label for a given {@code groupid:artifactid} notation.
* You can use only {@code artifactId} too.
*
* Feature related to messaging.
* @param ivyNotation ivy notation of a stub
* @param labelName name of the label to trigger
* @return true - if managed to run a trigger
*/
boolean trigger(String ivyNotation, String labelName);
/**
* Triggers an event by a given label.
*
* Feature related to messaging.
* @param labelName name of the label to trigger
* @return true - if managed to run a trigger
*/
boolean trigger(String labelName);
/**
* Triggers all possible events.
*
* Feature related to messaging.
* @return true - if managed to run a trigger
*/
boolean trigger();
/**
* Feature related to messaging.
* @return a mapping of ivy notation of a dependency to all the labels it has.
*/
Map<String, Collection<String>> labels();
}
为方便起见,接口扩展了 ,因此您只需要一个
或测试中的 other。StubFinder
StubTrigger
StubTrigger
提供了以下触发消息的选项:
4.4.3. 按组和工件 ID 触发
stubFinder.trigger('org.springframework.cloud.contract.verifier.stubs:streamService', 'return_book_1')
4.5. 使用 Apache Camel 进行消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种与 Apache Camel 集成的简便方法。 对于提供的工件,它会自动下载存根并注册所需的 路线。
4.5.1. 将 Apache Camel 添加到项目中
您可以在 Classpath 上同时拥有 Apache Camel 和 Spring Cloud Contract Stub Runner。
请记住使用 .@AutoConfigureStubRunner
4.5.2. 禁用该功能
如果需要禁用此功能,请设置stubrunner.camel.enabled=false
4.5.3. 示例
假设我们有以下 Maven 存储库,其中包含为应用程序部署的存根。camelService
└── .m2
└── repository
└── io
└── codearte
└── accurest
└── stubs
└── camelService
├── 0.0.1-SNAPSHOT
│ ├── camelService-0.0.1-SNAPSHOT.pom
│ ├── camelService-0.0.1-SNAPSHOT-stubs.jar
│ └── maven-metadata-local.xml
└── maven-metadata-local.xml
进一步假设存根包含以下结构:
├── META-INF
│ └── MANIFEST.MF
└── repository
├── accurest
│ ├── bookDeleted.groovy
│ ├── bookReturned1.groovy
│ └── bookReturned2.groovy
└── mappings
现在考虑以下合约(我们给它们编号 1 和 2):
Contract.make {
label 'return_book_1'
input {
triggeredBy('bookReturnedTriggered()')
}
outputMessage {
sentTo('jms:output')
body('''{ "bookName" : "foo" }''')
headers {
header('BOOK-NAME', 'foo')
}
}
}
Contract.make {
label 'return_book_2'
input {
messageFrom('jms:input')
messageBody([
bookName: 'foo'
])
messageHeaders {
header('sample', 'header')
}
}
outputMessage {
sentTo('jms:output')
body([
bookName: 'foo'
])
headers {
header('BOOK-NAME', 'foo')
}
}
}
场景 1 (无输入消息)
要从标签触发消息,我们使用接口,如下所示:return_book_1
StubTrigger
stubFinder.trigger('return_book_1')
接下来,我们要监听发送到 的消息的输出 :jms:output
Exchange receivedMessage = consumerTemplate.receive('jms:output', 5000)
然后,收到的消息将传递以下断言:
receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.in.body)
receivedMessage.in.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)
由于已为您设置了路由,因此您可以向目标发送消息。jms:output
producerTemplate.
sendBodyAndHeaders('jms:input', new BookReturned('foo'), [sample: 'header'])
接下来,我们要监听发送到 的消息的输出,如下所示:jms:output
Exchange receivedMessage = consumerTemplate.receive('jms:output', 5000)
收到的消息将传递以下断言:
receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.in.body)
receivedMessage.in.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:jms:output
producerTemplate.
sendBodyAndHeaders('jms:delete', new BookReturned('foo'), [sample: 'header'])
4.6. 使用 Spring 集成的消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring 集成。对于提供的工件,它会自动下载 存根并注册所需的路由。
4.6.1. 将 Runner 添加到项目中
您可以在 Spring Integration 和 Spring Cloud Contract Stub Runner 上同时拥有
classpath 的请记住使用 .@AutoConfigureStubRunner
4.6.2. 禁用该功能
如果需要禁用此功能,请设置stubrunner.integration.enabled=false
4.6.3. 示例
假设您有以下 Maven 存储库,其中包含为应用程序部署的存根:integrationService
└── .m2
└── repository
└── io
└── codearte
└── accurest
└── stubs
└── integrationService
├── 0.0.1-SNAPSHOT
│ ├── integrationService-0.0.1-SNAPSHOT.pom
│ ├── integrationService-0.0.1-SNAPSHOT-stubs.jar
│ └── maven-metadata-local.xml
└── maven-metadata-local.xml
进一步假设存根包含以下结构:
├── META-INF
│ └── MANIFEST.MF
└── repository
├── accurest
│ ├── bookDeleted.groovy
│ ├── bookReturned1.groovy
│ └── bookReturned2.groovy
└── mappings
考虑以下合约(编号 1 和 2):
Contract.make {
label 'return_book_1'
input {
triggeredBy('bookReturnedTriggered()')
}
outputMessage {
sentTo('output')
body('''{ "bookName" : "foo" }''')
headers {
header('BOOK-NAME', 'foo')
}
}
}
Contract.make {
label 'return_book_2'
input {
messageFrom('input')
messageBody([
bookName: 'foo'
])
messageHeaders {
header('sample', 'header')
}
}
outputMessage {
sentTo('output')
body([
bookName: 'foo'
])
headers {
header('BOOK-NAME', 'foo')
}
}
}
现在考虑以下 Spring 集成路由:
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:beans="http://www.springframework.org/schema/beans"
xmlns="http://www.springframework.org/schema/integration"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/integration
http://www.springframework.org/schema/integration/spring-integration.xsd">
<!-- REQUIRED FOR TESTING -->
<bridge input-channel="output"
output-channel="outputTest"/>
<channel id="outputTest">
<queue/>
</channel>
</beans:beans>
这些示例适用于三种情况:
场景 1 (无输入消息)
要从标签触发消息,请使用接口,如
遵循:return_book_1
StubTrigger
stubFinder.trigger('return_book_1')
以下清单显示了如何侦听 message sent to 的输出:jms:output
Message<?> receivedMessage = messaging.receive('outputTest')
收到的消息将传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:jms:output
messaging.send(new BookReturned('foo'), [sample: 'header'], 'input')
以下清单显示了如何侦听 message sent to 的输出:jms:output
Message<?> receivedMessage = messaging.receive('outputTest')
收到的消息将传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:jms:input
messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')
4.7. 使用 Spring Cloud Stream 进行消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring Stream 集成。对于提供的工件,它会自动下载 stubs 并注册所需的路由。
如果 Stub Runner 与 Stream 或字符串的集成
首先解析为 channel 的 ,并且不存在
destination 解析为通道名称。messageFrom sentTo destination destination |
如果要使用 Spring Cloud Stream,请记得在 上添加依赖,如下所示: Maven 系列
Gradle
|
4.7.1. 将 Runner 添加到项目中
您可以在 Spring Cloud Stream 和 Spring Cloud Contract Stub Runner 上同时拥有
classpath 的请记住使用 .@AutoConfigureStubRunner
4.7.2. 禁用该功能
如果需要禁用此功能,请设置stubrunner.stream.enabled=false
4.7.3. 示例
假设您有以下 Maven 存储库,其中包含为应用程序部署的存根:streamService
└── .m2
└── repository
└── io
└── codearte
└── accurest
└── stubs
└── streamService
├── 0.0.1-SNAPSHOT
│ ├── streamService-0.0.1-SNAPSHOT.pom
│ ├── streamService-0.0.1-SNAPSHOT-stubs.jar
│ └── maven-metadata-local.xml
└── maven-metadata-local.xml
进一步假设存根包含以下结构:
├── META-INF
│ └── MANIFEST.MF
└── repository
├── accurest
│ ├── bookDeleted.groovy
│ ├── bookReturned1.groovy
│ └── bookReturned2.groovy
└── mappings
考虑以下合约(编号 1 和 2):
Contract.make {
label 'return_book_1'
input { triggeredBy('bookReturnedTriggered()') }
outputMessage {
sentTo('returnBook')
body('''{ "bookName" : "foo" }''')
headers { header('BOOK-NAME', 'foo') }
}
}
Contract.make {
label 'return_book_2'
input {
messageFrom('bookStorage')
messageBody([
bookName: 'foo'
])
messageHeaders { header('sample', 'header') }
}
outputMessage {
sentTo('returnBook')
body([
bookName: 'foo'
])
headers { header('BOOK-NAME', 'foo') }
}
}
现在考虑以下 Spring 配置:
stubrunner.repositoryRoot: classpath:m2repo/repository/
stubrunner.ids: org.springframework.cloud.contract.verifier.stubs:streamService:0.0.1-SNAPSHOT:stubs
stubrunner.stubs-mode: remote
spring:
cloud:
stream:
bindings:
output:
destination: returnBook
input:
destination: bookStorage
server:
port: 0
debug: true
这些示例适用于三种情况:
场景 1 (无输入消息)
要从标签触发消息,请使用
遵循:return_book_1
StubTrigger
stubFinder.trigger('return_book_1')
以下示例说明如何侦听发送到通道的消息的输出,该通道为 :destination
returnBook
Message<?> receivedMessage = messaging.receive('returnBook')
收到的消息将传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)
由于路由已为您设置,因此您可以向 发送消息,如下所示:bookStorage
destination
messaging.send(new BookReturned('foo'), [sample: 'header'], 'bookStorage')
以下示例显示如何侦听发送到 的消息的输出 :returnBook
Message<?> receivedMessage = messaging.receive('returnBook')
收到的消息将传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:jms:output
messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')
4.8. 使用 Spring AMQP 进行消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring AMQP 的 Rabbit Template 集成。对于提供的工件,它 自动下载存根并注册所需的路由。
集成尝试独立工作(即,不与正在运行的
RabbitMQ 消息代理)。它期望在应用程序上下文中有一个
将其用作名为 的 Spring Boot 测试。因此,它可以使用 Mockito 间谍
验证和检查应用程序发送的消息的功能。RabbitTemplate
@SpyBean
在消息使用者端,存根运行程序认为所有带注释的
endpoints 和 application context 上的所有对象。@RabbitListener
SimpleMessageListenerContainer
由于消息通常发送到 AMQP 中的交易所,因此消息协定包含 exchange name 作为目标。另一端的消息侦听器绑定到 队列。绑定将 exchange 连接到队列。如果触发了消息协定,则 Spring AMQP 存根运行程序集成在应用程序上下文上查找绑定 匹配此交换。然后,它从 Spring 交换中收集队列并尝试 查找绑定到这些队列的消息侦听器。系统会为所有匹配项触发该消息 消息侦听器。
如果需要使用路由键,可以使用消息标头传递路由键。amqp_receivedRoutingKey
4.8.1. 将 Runner 添加到项目中
你可以在 Classpath 上同时拥有 Spring AMQP 和 Spring Cloud Contract Stub Runner,并且
设置属性 .请记住为您的测试类添加注释
跟。stubrunner.amqp.enabled=true
@AutoConfigureStubRunner
如果 Classpath 上已经有 Stream 和 Integration,则需要
通过设置 and 属性来显式禁用它们。stubrunner.stream.enabled=false stubrunner.integration.enabled=false |
4.8.2. 示例
假设您有以下 Maven 存储库,其中包含为应用程序部署的存根:spring-cloud-contract-amqp-test
└── .m2
└── repository
└── com
└── example
└── spring-cloud-contract-amqp-test
├── 0.4.0-SNAPSHOT
│ ├── spring-cloud-contract-amqp-test-0.4.0-SNAPSHOT.pom
│ ├── spring-cloud-contract-amqp-test-0.4.0-SNAPSHOT-stubs.jar
│ └── maven-metadata-local.xml
└── maven-metadata-local.xml
进一步假设存根包含以下结构:
├── META-INF
│ └── MANIFEST.MF
└── contracts
└── shouldProduceValidPersonData.groovy
然后考虑以下合约:
Contract.make {
// Human readable description
description 'Should produce valid person data'
// Label by means of which the output message can be triggered
label 'contract-test.person.created.event'
// input to the contract
input {
// the contract will be triggered by a method
triggeredBy('createPerson()')
}
// output message of the contract
outputMessage {
// destination to which the output message will be sent
sentTo 'contract-test.exchange'
headers {
header('contentType': 'application/json')
header('__TypeId__': 'org.springframework.cloud.contract.stubrunner.messaging.amqp.Person')
}
// the body of the output message
body([
id : $(consumer(9), producer(regex("[0-9]+"))),
name: "me"
])
}
}
现在考虑以下 Spring 配置:
stubrunner:
repositoryRoot: classpath:m2repo/repository/
ids: org.springframework.cloud.contract.verifier.stubs.amqp:spring-cloud-contract-amqp-test:0.4.0-SNAPSHOT:stubs
stubs-mode: remote
amqp:
enabled: true
server:
port: 0
触发消息
要使用上一节中的协定触发消息,请使用
遵循:StubTrigger
stubTrigger.trigger("contract-test.person.created.event")
该消息的目标为 ,因此 Spring AMQP 存根运行程序
Integration 会查找与此 Exchange 相关的绑定,如下例所示:contract-test.exchange
@Bean
public Binding binding() {
return BindingBuilder.bind(new Queue("test.queue"))
.to(new DirectExchange("contract-test.exchange")).with("#");
}
绑定定义绑定名为 .因此,以下侦听器
定义与 Contract 消息匹配并调用:test.queue
@Bean
public SimpleMessageListenerContainer simpleMessageListenerContainer(
ConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.setQueueNames("test.queue");
container.setMessageListener(listenerAdapter);
return container;
}
此外,以下带注释的侦听器匹配并被调用:
@RabbitListener(bindings = @QueueBinding(value = @Queue("test.queue"),
exchange = @Exchange(value = "contract-test.exchange",
ignoreDeclarationExceptions = "true")))
public void handlePerson(Person person) {
this.person = person;
}
该消息直接交给与匹配的 关联的 方法 。onMessage MessageListener SimpleMessageListenerContainer |
Spring AMQP 测试配置
为了避免 Spring AMQP 在我们的测试期间尝试连接到正在运行的代理,我们
配置 mock 。ConnectionFactory
要禁用 mocked ,请设置以下属性: ,如下所示:ConnectionFactory
stubrunner.amqp.mockConnection=false
stubrunner:
amqp:
mockConnection: false
4.9. 使用 Spring JMS 进行消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring JMS 集成。
该集成假定您有一个正在运行的 JMS 代理实例(例如 嵌入式代理)。activemq
4.9.1. 将 Runner 添加到项目中
您需要在 Classpath 上同时具有 Spring JMS 和 Spring Cloud Contract Stub Runner。请记住为您的测试类添加注释
跟。@AutoConfigureStubRunner
4.9.2. 示例
假设存根结构如下所示:
├── stubs
├── bookDeleted.groovy
├── bookReturned1.groovy
└── bookReturned2.groovy
进一步假设以下测试配置:
stubrunner:
repository-root: stubs:classpath:/stubs/
ids: my:stubs
stubs-mode: remote
spring:
activemq:
send-timeout: 1000
jms:
template:
receive-timeout: 1000
现在考虑以下合约(我们给它们编号 1 和 2):
Contract.make {
label 'return_book_1'
input {
triggeredBy('bookReturnedTriggered()')
}
outputMessage {
sentTo('output')
body('''{ "bookName" : "foo" }''')
headers {
header('BOOK-NAME', 'foo')
}
}
}
Contract.make {
label 'return_book_2'
input {
messageFrom('input')
messageBody([
bookName: 'foo'
])
messageHeaders {
header('sample', 'header')
}
}
outputMessage {
sentTo('output')
body([
bookName: 'foo'
])
headers {
header('BOOK-NAME', 'foo')
}
}
}
场景 1 (无输入消息)
要从标签触发消息,我们使用接口,如下所示:return_book_1
StubTrigger
stubFinder.trigger('return_book_1')
接下来,我们要监听发送到 的消息的输出 :output
TextMessage receivedMessage = (TextMessage) jmsTemplate.receive('output')
然后,收到的消息将传递以下断言:
receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.getText())
receivedMessage.getStringProperty('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)
由于已为您设置了路由,因此您可以向目标发送消息。output
jmsTemplate.
convertAndSend('input', new BookReturned('foo'), new MessagePostProcessor() {
@Override
Message postProcessMessage(Message message) throws JMSException {
message.setStringProperty("sample", "header")
return message
}
})
接下来,我们要监听发送到 的消息的输出,如下所示:output
TextMessage receivedMessage = (TextMessage) jmsTemplate.receive('output')
收到的消息将传递以下断言:
receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.getText())
receivedMessage.getStringProperty('BOOK-NAME') == 'foo'
场景 3(输入无输出)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:output
jmsTemplate.
convertAndSend('delete', new BookReturned('foo'), new MessagePostProcessor() {
@Override
Message postProcessMessage(Message message) throws JMSException {
message.setStringProperty("sample", "header")
return message
}
})
4.10. 使用 Spring Kafka 进行消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring Kafka 集成。
该集成假定您有一个正在运行的嵌入式 Kafka 代理实例(通过依赖项)。spring-kafka-test
4.10.1. 将 Runner 添加到项目中
您需要在 Classpath 上同时具有 Spring Kafka、Spring Kafka Test(以运行)和 Spring Cloud Contract Stub Runner。请记住为您的测试类添加注释
跟。@EmbeddedBroker
@AutoConfigureStubRunner
通过 Kafka 集成,为了轮询单个消息,我们需要在 Spring 上下文启动时注册一个消费者。这可能会导致这样一种情况:当您在使用者端时,Stub Runner 可以为相同的组 ID 和主题注册额外的使用者。这可能会导致只有一个组件实际轮询消息的情况。由于在消费者方面,您同时拥有 Spring Cloud Contract Stub Runner 和 Spring Cloud Contract Verifier Classpath,因此我们需要能够关闭此类行为。这是通过标志自动完成的,这将禁用 Contact Verifier 使用者注册。如果您的应用程序既是 kafka 消息的使用者又是生成者,则可能需要在生成的测试的基类中手动将该属性切换到。stubrunner.kafka.initializer.enabled
false
4.10.2. 示例
假设存根结构如下所示:
├── stubs
├── bookDeleted.groovy
├── bookReturned1.groovy
└── bookReturned2.groovy
进一步假设以下测试配置(注意指向嵌入式代理的 IP via ):spring.kafka.bootstrap-servers
${spring.embedded.kafka.brokers}
stubrunner:
repository-root: stubs:classpath:/stubs/
ids: my:stubs
stubs-mode: remote
spring:
kafka:
bootstrap-servers: ${spring.embedded.kafka.brokers}
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
properties:
"spring.json.trusted.packages": "*"
consumer:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
"spring.json.trusted.packages": "*"
group-id: groupId
如果您的应用程序使用非整数记录键,则需要相应地设置 and 属性,因为 Kafka 反序列化需要非 null
record keys 设置为 integer 类型。spring.kafka.producer.key-serializer spring.kafka.consumer.key-deserializer |
现在考虑以下合约(我们给它们编号 1 和 2):
场景 1 (无输入消息)
要从标签触发消息,我们使用接口,如下所示:return_book_1
StubTrigger
stubFinder.trigger('return_book_1')
接下来,我们要监听发送到 的消息的输出 :output
Message receivedMessage = receiveFromOutput()
然后,收到的消息将传递以下断言:
assert receivedMessage != null
assert assertThatBodyContainsBookNameFoo(receivedMessage.getPayload())
assert receivedMessage.getHeaders().get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)
由于已为您设置了路由,因此您可以向目标发送消息。output
Message message = MessageBuilder.createMessage(new BookReturned('foo'), new MessageHeaders([sample: "header",]))
kafkaTemplate.setDefaultTopic('input')
kafkaTemplate.send(message)
Message message = MessageBuilder.createMessage(new BookReturned('bar'), new MessageHeaders([kafka_messageKey: "bar5150",]))
kafkaTemplate.setDefaultTopic('input2')
kafkaTemplate.send(message)
接下来,我们要监听发送到 的消息的输出,如下所示:output
Message receivedMessage = receiveFromOutput()
Message receivedMessage = receiveFromOutput()
收到的消息将传递以下断言:
assert receivedMessage != null
assert assertThatBodyContainsBookNameFoo(receivedMessage.getPayload())
assert receivedMessage.getHeaders().get('BOOK-NAME') == 'foo'
assert receivedMessage != null
assert assertThatBodyContainsBookName(receivedMessage.getPayload(), 'bar')
assert receivedMessage.getHeaders().get('BOOK-NAME') == 'bar'
assert receivedMessage.getHeaders().get("kafka_receivedMessageKey") == 'bar5150'
场景 3(输入无输出)
由于已为您设置了路由,因此您可以向目标发送消息,如下所示:output
Message message = MessageBuilder.createMessage(new BookReturned('foo'), new MessageHeaders([sample: "header",]))
kafkaTemplate.setDefaultTopic('delete')
kafkaTemplate.send(message)
5. Spring Cloud Contract Stub 运行器
您在使用 Spring Cloud Contract Verifier 时可能会遇到的问题之一是 将生成的 WireMock JSON 存根从服务器端传递到客户端(或传递给 各种客户端)。在消息传递的客户端生成方面也是如此。
复制 JSON 文件并手动设置客户端消息收发不在 问题。这就是我们引入 Spring Cloud Contract Stub Runner 的原因。它可以 自动下载并运行存根。
5.1. 快照版本
您可以将其他快照存储库添加到您的文件中以使用 snapshot
版本,这些版本在每次成功构建后自动上传,如下所示:build.gradle
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-releases</id>
<name>Spring Releases</name>
<url>https://repo.spring.io/release</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-releases</id>
<name>Spring Releases</name>
<url>https://repo.spring.io/release</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
/*
We need to use the [buildscript {}] section when we have to modify
the classpath for the plugins. If that's not the case this section
can be skipped.
If you don't need to modify the classpath (e.g. add a Pact dependency),
then you can just set the [pluginManagement {}] section in [settings.gradle] file.
// settings.gradle
pluginManagement {
repositories {
// for snapshots
maven {url "https://repo.spring.io/snapshot"}
// for milestones
maven {url "https://repo.spring.io/milestone"}
// for GA versions
gradlePluginPortal()
}
}
*/
buildscript {
repositories {
mavenCentral()
mavenLocal()
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/release" }
}
5.2. 将存根发布为 JAR
将存根发布为 jar 的最简单方法是集中保存存根的方式。 例如,您可以将它们作为 jar 保存在 Maven 存储库中。
对于 Maven 和 Gradle,设置已准备就绪。但是,您可以自定义 如果你愿意,就去吧。 |
以下示例演示如何将存根发布为 jar:
<!-- First disable the default jar setup in the properties section -->
<!-- we don't want the verifier to do a jar for us -->
<spring.cloud.contract.verifier.skip>true</spring.cloud.contract.verifier.skip>
<!-- Next add the assembly plugin to your build -->
<!-- we want the assembly plugin to generate the JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>stub</id>
<phase>prepare-package</phase>
<goals>
<goal>single</goal>
</goals>
<inherited>false</inherited>
<configuration>
<attach>true</attach>
<descriptors>
${basedir}/src/assembly/stub.xml
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
<!-- Finally setup your assembly. Below you can find the contents of src/main/assembly/stub.xml -->
<assembly
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 https://maven.apache.org/xsd/assembly-1.1.3.xsd">
<id>stubs</id>
<formats>
<format>jar</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>src/main/java</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>**com/example/model/*.*</include>
</includes>
</fileSet>
<fileSet>
<directory>${project.build.directory}/classes</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>**com/example/model/*.*</include>
</includes>
</fileSet>
<fileSet>
<directory>${project.build.directory}/snippets/stubs</directory>
<outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/mappings</outputDirectory>
<includes>
<include>**/*</include>
</includes>
</fileSet>
<fileSet>
<directory>${basedir}/src/test/resources/contracts</directory>
<outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/contracts</outputDirectory>
<includes>
<include>**/*.groovy</include>
</includes>
</fileSet>
</fileSets>
</assembly>
ext {
contractsDir = file("mappings")
stubsOutputDirRoot = file("${project.buildDir}/production/${project.name}-stubs/")
}
// Automatically added by plugin:
// copyContracts - copies contracts to the output folder from which JAR will be created
// verifierStubsJar - JAR with a provided stub suffix
// the presented publication is also added by the plugin but you can modify it as you wish
publishing {
publications {
stubs(MavenPublication) {
artifactId "${project.name}-stubs"
artifact verifierStubsJar
}
}
}
5.3. Stub Runner 核心
存根运行器核心为服务协作者运行存根。将存根视为 services 允许您使用 stub-runner 作为 Consumer 驱动的 Contract 的实现。
Stub Runner 允许您自动下载提供的依赖项的存根(或 从 Classpath 中选择它们),为它们启动 WireMock 服务器,并为它们提供适当的 存根定义。对于消息传递,定义了特殊的存根路由。
5.3.1. 检索存根
您可以从以下获取存根的选项中进行选择:
-
基于 Aether 的解决方案,可从 Artifactory 或 Nexus 下载带有存根的 JAR
-
Classpath-scanning 解决方案,它使用模式搜索 Classpath 以检索存根
-
编写您自己的 实现以实现完全自定义
org.springframework.cloud.contract.stubrunner.StubDownloaderBuilder
后一个示例在 Custom Stub Runner 部分中介绍。
下载 Stub
您可以使用开关控制存根的下载。它从枚举中选取值。您可以使用以下选项:stubsMode
StubRunnerProperties.StubsMode
-
StubRunnerProperties.StubsMode.CLASSPATH
(默认值):从 Classpath 中选择存根 -
StubRunnerProperties.StubsMode.LOCAL
:从本地存储中选取存根(例如.m2
) -
StubRunnerProperties.StubsMode.REMOTE
:从远程位置选取存根
以下示例从本地位置选取存根:
@AutoConfigureStubRunner(repositoryRoot="https://foo.bar", ids = "com.example:beer-api-producer:+:stubs:8095", stubsMode = StubRunnerProperties.StubsMode.LOCAL)
类路径扫描
如果将属性设置为(或未设置任何内容,因为是默认值),则会扫描 Classpath。
请考虑以下示例:stubsMode
StubRunnerProperties.StubsMode.CLASSPATH
CLASSPATH
@AutoConfigureStubRunner(ids = {
"com.example:beer-api-producer:+:stubs:8095",
"com.example.foo:bar:1.0.0:superstubs:8096"
})
您可以将依赖项添加到 Classpath 中,如下所示:
<dependency>
<groupId>com.example</groupId>
<artifactId>beer-api-producer-restdocs</artifactId>
<classifier>stubs</classifier>
<version>0.0.1-SNAPSHOT</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.example.thing1</groupId>
<artifactId>thing2</artifactId>
<classifier>superstubs</classifier>
<version>1.0.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
testCompile("com.example:beer-api-producer-restdocs:0.0.1-SNAPSHOT:stubs") {
transitive = false
}
testCompile("com.example.thing1:thing2:1.0.0:superstubs") {
transitive = false
}
然后扫描 Classpath 上的指定位置。为
将扫描以下位置:com.example:beer-api-producer-restdocs
-
/META-INF/com.example/beer-api-producer-restdocs/*/.*
-
/contracts/com.example/beer-api-producer-restdocs/*/.*
-
/mappings/com.example/beer-api-producer-restdocs/*/.*
对于 ,将扫描以下位置:com.example.thing1:thing2
-
/META-INF/com.example.thing1/thing2/*/.*
-
/contracts/com.example.thing1/thing2/*/.*
-
/mappings/com.example.thing1/thing2/*/.*
在打包 producer stub 的 |
为了实现正确的存根打包,生产者将按如下方式设置 Contract:
└── src
└── test
└── resources
└── contracts
└── com.example
└── beer-api-producer-restdocs
└── nested
└── contract3.groovy
通过使用 Maven 程序集
插件或 Gradle Jar 任务,您必须创建以下内容
结构中:
└── META-INF
└── com.example
└── beer-api-producer-restdocs
└── 2.0.0
├── contracts
│ └── nested
│ └── contract2.groovy
└── mappings
└── mapping.json
通过维护此结构,可以扫描 Classpath,您可以从消息传递或 HTTP 存根,而无需下载构件。
配置 HTTP 服务器存根
Stub Runner 有一个 a 的概念,它抽象了底层
HTTP 服务器的具体实现(例如,WireMock 是其中一种实现)。
有时,您需要对存根服务器执行一些额外的调整(这对于给定的实现是具体的)。
为此,Stub Runner 为您提供
注解中可用的属性和
JUnit 规则,可通过系统属性进行访问,您可以在其中提供
您的接口实现。实现可以改变
给定 HTTP 服务器存根的配置文件。HttpServerStub
httpServerStubConfigurer
org.springframework.cloud.contract.stubrunner.HttpServerStubConfigurer
Spring Cloud Contract Stub Runner 附带一个实现,您可以
可以扩展 WireMock: .
在该方法中,
您可以为给定的存根提供自己的自定义配置。用途
case 可能会在 HTTPS 端口上为给定的项目 ID 启动 WireMock。以下内容
示例展示了如何做到这一点:org.springframework.cloud.contract.stubrunner.provider.wiremock.WireMockHttpServerStubConfigurer
configure
@CompileStatic
static class HttpsForFraudDetection extends WireMockHttpServerStubConfigurer {
private static final Log log = LogFactory.getLog(HttpsForFraudDetection)
@Override
WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration, HttpServerStubConfiguration httpServerStubConfiguration) {
if (httpServerStubConfiguration.stubConfiguration.artifactId == "fraudDetectionServer") {
int httpsPort = SocketUtils.findAvailableTcpPort()
log.info("Will set HTTPs port [" + httpsPort + "] for fraud detection server")
return httpStubConfiguration
.httpsPort(httpsPort)
}
return httpStubConfiguration
}
}
然后,您可以将其与注释一起重复使用,如下所示:@AutoConfigureStubRunner
@AutoConfigureStubRunner(mappingsOutputFolder = "target/outputmappings/",
httpServerStubConfigurer = HttpsForFraudDetection)
每当找到 HTTPS 端口时,它优先于 HTTP 端口。
5.3.2. 运行 stub
本节介绍如何执行 stub。它包含以下主题:
HTTP 存根
存根在 JSON 文档中定义,其语法在 WireMock 文档中定义
以下示例在 JSON 中定义一个存根:
{
"request": {
"method": "GET",
"url": "/ping"
},
"response": {
"status": 200,
"body": "pong",
"headers": {
"Content-Type": "text/plain"
}
}
}
查看已注册的映射
每个存根协作者都会在终端节点下公开一个已定义映射的列表。__/admin/
您还可以使用该属性将映射转储到文件。
对于基于注释的方法,它类似于以下示例:mappingsOutputFolder
@AutoConfigureStubRunner(ids="a.b.c:loanIssuance,a.b.c:fraudDetectionServer",
mappingsOutputFolder = "target/outputmappings/")
对于 JUnit 方法,它类似于以下示例:
@ClassRule @Shared StubRunnerRule rule = new StubRunnerRule()
.repoRoot("https://some_url")
.downloadStub("a.b.c", "loanIssuance")
.downloadStub("a.b.c:fraudDetectionServer")
.withMappingsOutputFolder("target/outputmappings")
然后,如果您签出文件夹,您将看到以下结构;target/outputmappings
.
├── fraudDetectionServer_13705
└── loanIssuance_12255
这意味着注册了两个存根。 已在 port 和 port 注册。如果我们查看其中一个文件,我们将看到(对于 WireMock)
可用于给定服务器的映射:fraudDetectionServer
13705
loanIssuance
12255
[{
"id" : "f9152eb9-bf77-4c38-8289-90be7d10d0d7",
"request" : {
"url" : "/name",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "fraudDetectionServer"
},
"uuid" : "f9152eb9-bf77-4c38-8289-90be7d10d0d7"
},
...
]
消息存根
根据提供的 Stub Runner 依赖项和 DSL,消息收发路由会自动设置。
5.4. Stub Runner JUnit 规则和 Stub Runner JUnit5 扩展
Stub Runner 附带一个 JUnit 规则,允许您下载和运行给定的 stub group 和工件 ID,如下例所示:
@ClassRule
public static StubRunnerRule rule = new StubRunnerRule().repoRoot(repoRoot())
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.downloadStub("org.springframework.cloud.contract.verifier.stubs",
"loanIssuance")
.downloadStub(
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer");
@BeforeClass
@AfterClass
public static void setupProps() {
System.clearProperty("stubrunner.repository.root");
System.clearProperty("stubrunner.classifier");
}
A 也可用于 JUnit 5。 并以非常相似的方式工作。在规则或扩展名
执行时,Stub Runner 会连接到您的 Maven 存储库,并且对于给定的
依赖项尝试:StubRunnerExtension
StubRunnerRule
StubRunnerExtension
-
下载
-
在本地缓存它们
-
将它们解压缩到临时文件夹
-
从提供的随机端口为每个 Maven 依赖项启动一个 WireMock 服务器 端口范围或提供的端口
-
向 WireMock 服务器提供所有作为有效 WireMock 定义的 JSON 文件
-
发送消息(记得传递 interface 的实现)
MessageVerifier
Stub Runner 使用 Eclipse Aether 机制下载 Maven 依赖项。 查看他们的文档以获取更多信息。
由于 和 实现 they let
您可以找到 Started Stubs,如下例所示:StubRunnerRule
StubRunnerExtension
StubFinder
package org.springframework.cloud.contract.stubrunner;
import java.net.URL;
import java.util.Collection;
import java.util.Map;
import org.springframework.cloud.contract.spec.Contract;
/**
* Contract for finding registered stubs.
*
* @author Marcin Grzejszczak
*/
public interface StubFinder extends StubTrigger {
/**
* For the given groupId and artifactId tries to find the matching URL of the running
* stub.
* @param groupId - might be null. In that case a search only via artifactId takes
* place
* @param artifactId - artifact id of the stub
* @return URL of a running stub or throws exception if not found
* @throws StubNotFoundException in case of not finding a stub
*/
URL findStubUrl(String groupId, String artifactId) throws StubNotFoundException;
/**
* For the given Ivy notation {@code [groupId]:artifactId:[version]:[classifier]}
* tries to find the matching URL of the running stub. You can also pass only
* {@code artifactId}.
* @param ivyNotation - Ivy representation of the Maven artifact
* @return URL of a running stub or throws exception if not found
* @throws StubNotFoundException in case of not finding a stub
*/
URL findStubUrl(String ivyNotation) throws StubNotFoundException;
/**
* @return all running stubs
*/
RunningStubs findAllRunningStubs();
/**
* @return the list of Contracts
*/
Map<StubConfiguration, Collection<Contract>> getContracts();
}
以下示例提供了有关使用 Stub Runner 的更多详细信息:
@ClassRule
@Shared
StubRunnerRule rule = new StubRunnerRule()
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.repoRoot(StubRunnerRuleSpec.getResource("/m2repo/repository").toURI().toString())
.downloadStub("org.springframework.cloud.contract.verifier.stubs", "loanIssuance")
.downloadStub("org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer")
.withMappingsOutputFolder("target/outputmappingsforrule")
def 'should start WireMock servers'() {
expect: 'WireMocks are running'
rule.findStubUrl('org.springframework.cloud.contract.verifier.stubs', 'loanIssuance') != null
rule.findStubUrl('loanIssuance') != null
rule.findStubUrl('loanIssuance') == rule.findStubUrl('org.springframework.cloud.contract.verifier.stubs', 'loanIssuance')
rule.findStubUrl('org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer') != null
and:
rule.findAllRunningStubs().isPresent('loanIssuance')
rule.findAllRunningStubs().isPresent('org.springframework.cloud.contract.verifier.stubs', 'fraudDetectionServer')
rule.findAllRunningStubs().isPresent('org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer')
and: 'Stubs were registered'
"${rule.findStubUrl('loanIssuance').toString()}/name".toURL().text == 'loanIssuance'
"${rule.findStubUrl('fraudDetectionServer').toString()}/name".toURL().text == 'fraudDetectionServer'
}
def 'should output mappings to output folder'() {
when:
def url = rule.findStubUrl('fraudDetectionServer')
then:
new File("target/outputmappingsforrule", "fraudDetectionServer_${url.port}").exists()
}
@Test
public void should_start_wiremock_servers() throws Exception {
// expect: 'WireMocks are running'
then(rule.findStubUrl("org.springframework.cloud.contract.verifier.stubs",
"loanIssuance")).isNotNull();
then(rule.findStubUrl("loanIssuance")).isNotNull();
then(rule.findStubUrl("loanIssuance")).isEqualTo(rule.findStubUrl(
"org.springframework.cloud.contract.verifier.stubs", "loanIssuance"));
then(rule.findStubUrl(
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer"))
.isNotNull();
// and:
then(rule.findAllRunningStubs().isPresent("loanIssuance")).isTrue();
then(rule.findAllRunningStubs().isPresent(
"org.springframework.cloud.contract.verifier.stubs",
"fraudDetectionServer")).isTrue();
then(rule.findAllRunningStubs().isPresent(
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer"))
.isTrue();
// and: 'Stubs were registered'
then(httpGet(rule.findStubUrl("loanIssuance").toString() + "/name"))
.isEqualTo("loanIssuance");
then(httpGet(rule.findStubUrl("fraudDetectionServer").toString() + "/name"))
.isEqualTo("fraudDetectionServer");
}
// Visible for Junit
@RegisterExtension
static StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
.repoRoot(repoRoot()).stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.downloadStub("org.springframework.cloud.contract.verifier.stubs",
"loanIssuance")
.downloadStub(
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer")
.withMappingsOutputFolder("target/outputmappingsforrule");
@BeforeAll
@AfterAll
static void setupProps() {
System.clearProperty("stubrunner.repository.root");
System.clearProperty("stubrunner.classifier");
}
private static String repoRoot() {
try {
return StubRunnerRuleJUnitTest.class.getResource("/m2repo/repository/")
.toURI().toString();
}
catch (Exception e) {
return "";
}
}
有关更多信息,请参阅 JUnit 和 Spring 的通用属性 如何应用 Stub Runner 的全局配置。
要将 JUnit 规则或 JUnit 5 扩展与消息收发一起使用,您必须为规则生成器提供接口的实现(例如, )。
如果不这样做,则每当尝试发送消息时,都会引发异常。MessageVerifier rule.messageVerifier(new MyMessageVerifier()) |
5.4.1. Maven 设置
存根下载程序遵循其他本地存储库文件夹的 Maven 设置。 当前不考虑存储库和配置文件的身份验证详细信息。 因此,您需要使用上述属性来指定它。
5.4.2. 提供固定端口
您还可以在固定端口上运行存根。您可以通过两种不同的方式来实现。 一种是在 properties 中传递,另一种是使用 Fluent API JUnit 规则。
5.4.3. Fluent API
使用 or 时,您可以添加要下载的存根
然后传递最后下载的存根的端口。以下示例显示了如何执行此操作:StubRunnerRule
StubRunnerExtension
@ClassRule
public static StubRunnerRule rule = new StubRunnerRule().repoRoot(repoRoot())
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.downloadStub("org.springframework.cloud.contract.verifier.stubs",
"loanIssuance")
.withPort(12345).downloadStub(
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer:12346");
@BeforeClass
@AfterClass
public static void setupProps() {
System.clearProperty("stubrunner.repository.root");
System.clearProperty("stubrunner.classifier");
}
对于前面的示例,以下测试有效:
then(rule.findStubUrl("loanIssuance"))
.isEqualTo(URI.create("http://localhost:12345").toURL());
then(rule.findStubUrl("fraudDetectionServer"))
.isEqualTo(URI.create("http://localhost:12346").toURL());
5.4.4. 带弹簧的短管 Runner
Stub Runner with Spring 设置 Stub Runner 项目的 Spring 配置。
通过在配置文件中提供存根列表,Stub Runner 会自动下载 并在 WireMock 中注册选定的存根。
如果你想找到你的存根依赖项的 URL,你可以自动装配接口并使用
其方法,如下所示:StubFinder
@ContextConfiguration(classes = Config, loader = SpringBootContextLoader)
@SpringBootTest(properties = [" stubrunner.cloud.enabled=false",
'foo=${stubrunner.runningstubs.fraudDetectionServer.port}',
'fooWithGroup=${stubrunner.runningstubs.org.springframework.cloud.contract.verifier.stubs.fraudDetectionServer.port}'])
@AutoConfigureStubRunner(mappingsOutputFolder = "target/outputmappings/",
httpServerStubConfigurer = HttpsForFraudDetection)
@ActiveProfiles("test")
class StubRunnerConfigurationSpec extends Specification {
@Autowired
StubFinder stubFinder
@Autowired
Environment environment
@StubRunnerPort("fraudDetectionServer")
int fraudDetectionServerPort
@StubRunnerPort("org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer")
int fraudDetectionServerPortWithGroupId
@Value('${foo}')
Integer foo
@BeforeClass
@AfterClass
void setupProps() {
System.clearProperty("stubrunner.repository.root")
System.clearProperty("stubrunner.classifier")
WireMockHttpServerStubAccessor.clear()
}
def 'should mark all ports as random'() {
expect:
WireMockHttpServerStubAccessor.everyPortRandom()
}
def 'should start WireMock servers'() {
expect: 'WireMocks are running'
stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs', 'loanIssuance') != null
stubFinder.findStubUrl('loanIssuance') != null
stubFinder.findStubUrl('loanIssuance') == stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs', 'loanIssuance')
stubFinder.findStubUrl('loanIssuance') == stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs:loanIssuance')
stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs:loanIssuance:0.0.1-SNAPSHOT') == stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs:loanIssuance:0.0.1-SNAPSHOT:stubs')
stubFinder.findStubUrl('org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer') != null
and:
stubFinder.findAllRunningStubs().isPresent('loanIssuance')
stubFinder.findAllRunningStubs().isPresent('org.springframework.cloud.contract.verifier.stubs', 'fraudDetectionServer')
stubFinder.findAllRunningStubs().isPresent('org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer')
and: 'Stubs were registered'
"${stubFinder.findStubUrl('loanIssuance').toString()}/name".toURL().text == 'loanIssuance'
"${stubFinder.findStubUrl('fraudDetectionServer').toString()}/name".toURL().text == 'fraudDetectionServer'
and: 'Fraud Detection is an HTTPS endpoint'
stubFinder.findStubUrl('fraudDetectionServer').toString().startsWith("https")
}
def 'should throw an exception when stub is not found'() {
when:
stubFinder.findStubUrl('nonExistingService')
then:
thrown(StubNotFoundException)
when:
stubFinder.findStubUrl('nonExistingGroupId', 'nonExistingArtifactId')
then:
thrown(StubNotFoundException)
}
def 'should register started servers as environment variables'() {
expect:
environment.getProperty("stubrunner.runningstubs.loanIssuance.port") != null
stubFinder.findAllRunningStubs().getPort("loanIssuance") == (environment.getProperty("stubrunner.runningstubs.loanIssuance.port") as Integer)
and:
environment.getProperty("stubrunner.runningstubs.fraudDetectionServer.port") != null
stubFinder.findAllRunningStubs().getPort("fraudDetectionServer") == (environment.getProperty("stubrunner.runningstubs.fraudDetectionServer.port") as Integer)
and:
environment.getProperty("stubrunner.runningstubs.fraudDetectionServer.port") != null
stubFinder.findAllRunningStubs().getPort("fraudDetectionServer") == (environment.getProperty("stubrunner.runningstubs.org.springframework.cloud.contract.verifier.stubs.fraudDetectionServer.port") as Integer)
}
def 'should be able to interpolate a running stub in the passed test property'() {
given:
int fraudPort = stubFinder.findAllRunningStubs().getPort("fraudDetectionServer")
expect:
fraudPort > 0
environment.getProperty("foo", Integer) == fraudPort
environment.getProperty("fooWithGroup", Integer) == fraudPort
foo == fraudPort
}
@Issue("#573")
def 'should be able to retrieve the port of a running stub via an annotation'() {
given:
int fraudPort = stubFinder.findAllRunningStubs().getPort("fraudDetectionServer")
expect:
fraudPort > 0
fraudDetectionServerPort == fraudPort
fraudDetectionServerPortWithGroupId == fraudPort
}
def 'should dump all mappings to a file'() {
when:
def url = stubFinder.findStubUrl("fraudDetectionServer")
then:
new File("target/outputmappings/", "fraudDetectionServer_${url.port}").exists()
}
@Configuration
@EnableAutoConfiguration
static class Config {}
@CompileStatic
static class HttpsForFraudDetection extends WireMockHttpServerStubConfigurer {
private static final Log log = LogFactory.getLog(HttpsForFraudDetection)
@Override
WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration, HttpServerStubConfiguration httpServerStubConfiguration) {
if (httpServerStubConfiguration.stubConfiguration.artifactId == "fraudDetectionServer") {
int httpsPort = SocketUtils.findAvailableTcpPort()
log.info("Will set HTTPs port [" + httpsPort + "] for fraud detection server")
return httpStubConfiguration
.httpsPort(httpsPort)
}
return httpStubConfiguration
}
}
}
这样做取决于以下配置文件:
stubrunner:
repositoryRoot: classpath:m2repo/repository/
ids:
- org.springframework.cloud.contract.verifier.stubs:loanIssuance
- org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer
- org.springframework.cloud.contract.verifier.stubs:bootService
stubs-mode: remote
除了使用属性之外,您还可以使用 .
以下示例通过在 annotation 上设置值来获得相同的结果:@AutoConfigureStubRunner
@AutoConfigureStubRunner(
ids = ["org.springframework.cloud.contract.verifier.stubs:loanIssuance",
"org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer",
"org.springframework.cloud.contract.verifier.stubs:bootService"],
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "classpath:m2repo/repository/")
Stub Runner Spring 按以下方式注册环境变量
对于每个已注册的 WireMock 服务器。以下示例显示了 和 的存根运行程序 ID:com.example:thing1
com.example:thing2
-
stubrunner.runningstubs.thing1.port
-
stubrunner.runningstubs.com.example.thing1.port
-
stubrunner.runningstubs.thing2.port
-
stubrunner.runningstubs.com.example.thing2.port
您可以在代码中引用这些值。
您还可以使用 Comments 注入正在运行的存根的端口。
注释的值可以是 the 或只是 .
以下示例作品显示了 和 的 Stub Runner ID。@StubRunnerPort
groupid:artifactid
artifactid
com.example:thing1
com.example:thing2
@StubRunnerPort("thing1")
int thing1Port;
@StubRunnerPort("com.example:thing2")
int thing2Port;
5.5. Stub Runner Spring Cloud
Stub Runner 可以与 Spring Cloud 集成。
有关实际示例,请参阅:
5.5.1. Stubbing 服务发现
最重要的特征是它存根:Stub Runner Spring Cloud
-
DiscoveryClient
-
Ribbon
ServerList
这意味着,无论您使用的是 Zookeeper、Consul、Eureka 还是任何东西
否则,您在测试中不需要它。我们正在启动您的
依赖项,并且我们会告诉您的应用程序,无论何时使用 ,都要加载
balanced 或直接调用这些存根服务器
而不是调用真正的 Service Discovery 工具。Feign
RestTemplate
DiscoveryClient
例如,以下测试通过:
def 'should make service discovery work'() {
expect: 'WireMocks are running'
"${stubFinder.findStubUrl('loanIssuance').toString()}/name".toURL().text == 'loanIssuance'
"${stubFinder.findStubUrl('fraudDetectionServer').toString()}/name".toURL().text == 'fraudDetectionServer'
and: 'Stubs can be reached via load service discovery'
restTemplate.getForObject('http://loanIssuance/name', String) == 'loanIssuance'
restTemplate.getForObject('http://someNameThatShouldMapFraudDetectionServer/name', String) == 'fraudDetectionServer'
}
请注意,前面的示例需要以下配置文件:
stubrunner:
idsToServiceIds:
ivyNotation: someValueInsideYourCode
fraudDetectionServer: someNameThatShouldMapFraudDetectionServer
测试配置文件和服务发现
在集成测试中,您通常不想调用任何一个发现服务(例如 Eureka) 或 Config Server。因此,您需要创建一个额外的测试配置,并在其中禁用 这些功能。
由于 spring-cloud-commons
的某些限制,
为此,您必须禁用这些属性
在如下例(对于 Eureka)的 static 块中:
//Hack to work around https://github.com/spring-cloud/spring-cloud-commons/issues/156
static {
System.setProperty("eureka.client.enabled", "false");
System.setProperty("spring.cloud.config.failFast", "false");
}
5.5.2. 其他配置
您可以使用 map 将存根的 与应用程序的名称进行匹配。
您可以通过设置为 You can disable Stub Runner support 来禁用 Stub Runner 功能区支持,方法是设置为artifactId
stubrunner.idsToServiceIds:
stubrunner.cloud.ribbon.enabled
false
stubrunner.cloud.enabled
false
默认情况下,所有服务发现都是存根的。这意味着,无论您是否拥有
一个 existing ,其结果将被忽略。但是,如果要重用它,则可以设置为 ,然后现有结果为
与存根的合并。DiscoveryClient stubrunner.cloud.delegate.enabled true DiscoveryClient |
Stub Runner 使用的默认 Maven 配置可以调整 通过设置以下系统属性或设置相应的环境变量:
-
maven.repo.local
:自定义 maven 本地存储库位置的路径 -
org.apache.maven.user-settings
:自定义 maven 用户设置位置的路径 -
org.apache.maven.global-settings
:maven 全局设置位置的路径
5.6. 使用 Stub Runner 引导应用程序
Spring Cloud Contract Stub Runner Boot 是一个 Spring Boot 应用程序,它将 REST 端点公开给 触发消息标签并访问 WireMock 服务器。
其中一个用例是在已部署的应用程序上运行一些冒烟(端到端)测试。 有关更多信息,您可以查看 Spring Cloud Pipelines 项目。
5.6.1. Stub Runner 服务器
要使用 Stub Runner Server,请添加以下依赖项:
compile "org.springframework.cloud:spring-cloud-starter-stub-runner"
然后用 来注释一个类,构建一个胖 jar,它就可以工作了。@EnableStubRunnerServer
有关属性,请参阅 Stub Runner Spring 部分。
5.6.2. Stub Runner 服务器 Fat Jar
您可以从 Maven 下载独立的 JAR(例如,对于版本 2.0.1.RELEASE) 通过运行以下命令:
$ wget -O stub-runner.jar 'https://search.maven.org/remotecontent?filepath=org/springframework/cloud/spring-cloud-contract-stub-runner-boot/2.0.1.RELEASE/spring-cloud-contract-stub-runner-boot-2.0.1.RELEASE.jar'
$ java -jar stub-runner.jar --stubrunner.ids=... --stubrunner.repositoryRoot=...
5.6.3. Spring Cloud CLI
从 Spring Cloud CLI 项目的版本开始,您可以通过运行 来启动 Stub Runner Boot。1.4.0.RELEASE
spring cloud stubrunner
为了传递配置,您可以在当前工作目录下创建一个文件,
在名为 的子目录中,或在 中。该文件可能类似于以下内容
运行本地安装的存根的示例:stubrunner.yml
config
~/.spring-cloud
stubrunner:
stubsMode: LOCAL
ids:
- com.example:beer-api-producer:+:9876
然后,您可以从终端窗口调用以启动
Stub Runner 服务器。可在 port 获得。spring cloud stubrunner
8750
5.6.4. 端点
Stub Runner Boot 提供两个端点:
HTTP 协议
对于 HTTP,Stub Runner Boot 使以下终端节点可用:
-
GET :返回表示法中所有正在运行的存根的列表
/stubs
ivy:integer
-
GET :返回给定表示法的端口(当调用端点也可以是唯一时)
/stubs/{ivy}
ivy
ivy
artifactId
消息
对于消息收发,Stub Runner Boot 使以下终端节点可用:
-
GET :返回表示法中所有运行标签的列表
/triggers
ivy : [ label1, label2 …]
-
POST :使用
/triggers/{label}
label
-
POST :为给定表示法运行带有 a 的触发器 (调用终端节点时,也可以只调用)
/triggers/{ivy}/{label}
label
ivy
ivy
artifactId
5.6.5. 示例
以下示例显示了 Stub Runner Boot 的典型用法:
@ContextConfiguration(classes = StubRunnerBoot, loader = SpringBootContextLoader)
@SpringBootTest(properties = "spring.cloud.zookeeper.enabled=false")
@ActiveProfiles("test")
class StubRunnerBootSpec extends Specification {
@Autowired
StubRunning stubRunning
def setup() {
RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning),
new TriggerController(stubRunning))
}
def 'should return a list of running stub servers in "full ivy:port" notation'() {
when:
String response = RestAssuredMockMvc.get('/stubs').body.asString()
then:
def root = new JsonSlurper().parseText(response)
root.'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs' instanceof Integer
}
def 'should return a port on which a [#stubId] stub is running'() {
when:
def response = RestAssuredMockMvc.get("/stubs/${stubId}")
then:
response.statusCode == 200
Integer.valueOf(response.body.asString()) > 0
where:
stubId << ['org.springframework.cloud.contract.verifier.stubs:bootService:+:stubs',
'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs',
'org.springframework.cloud.contract.verifier.stubs:bootService:+',
'org.springframework.cloud.contract.verifier.stubs:bootService',
'bootService']
}
def 'should return 404 when missing stub was called'() {
when:
def response = RestAssuredMockMvc.get("/stubs/a:b:c:d")
then:
response.statusCode == 404
}
def 'should return a list of messaging labels that can be triggered when version and classifier are passed'() {
when:
String response = RestAssuredMockMvc.get('/triggers').body.asString()
then:
def root = new JsonSlurper().parseText(response)
root.'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs'?.containsAll(["delete_book", "return_book_1", "return_book_2"])
}
def 'should trigger a messaging label'() {
given:
StubRunning stubRunning = Mock()
RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning), new TriggerController(stubRunning))
when:
def response = RestAssuredMockMvc.post("/triggers/delete_book")
then:
response.statusCode == 200
and:
1 * stubRunning.trigger('delete_book')
}
def 'should trigger a messaging label for a stub with [#stubId] ivy notation'() {
given:
StubRunning stubRunning = Mock()
RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning), new TriggerController(stubRunning))
when:
def response = RestAssuredMockMvc.post("/triggers/$stubId/delete_book")
then:
response.statusCode == 200
and:
1 * stubRunning.trigger(stubId, 'delete_book')
where:
stubId << ['org.springframework.cloud.contract.verifier.stubs:bootService:stubs', 'org.springframework.cloud.contract.verifier.stubs:bootService', 'bootService']
}
def 'should throw exception when trigger is missing'() {
when:
RestAssuredMockMvc.post("/triggers/missing_label")
then:
Exception e = thrown(Exception)
e.message.contains("Exception occurred while trying to return [missing_label] label.")
e.message.contains("Available labels are")
e.message.contains("org.springframework.cloud.contract.verifier.stubs:loanIssuance:0.0.1-SNAPSHOT:stubs=[]")
e.message.contains("org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs=")
}
}
5.6.6. 带有服务发现的 Stub Runner 引导
使用 Stub Runner Boot 的一种方法是将其用作 “冒烟测试” 的存根源。那是什么意思? 假设您不想为了 Order Order 将 50 个微服务部署到测试环境中 以查看您的应用程序是否正常工作。您已经在构建过程中执行了一套测试, 但您还希望确保应用程序的打包有效。您可以 将应用程序部署到环境,启动它,然后对其运行几个测试以查看 它有效。我们可以将这些测试称为 “冒烟测试”,因为它们的目的是只检查少数几个 的测试场景。
这种方法的问题在于,如果您使用微服务,您很可能还会使用 使用服务发现工具。Stub Runner Boot 允许您通过启动 必需的存根,并在服务发现工具中注册它们。请考虑以下示例 使用 Eureka 进行这样的设置(假设 Eureka 已经在运行):
@SpringBootApplication
@EnableStubRunnerServer
@EnableEurekaClient
@AutoConfigureStubRunner
public class StubRunnerBootEurekaExample {
public static void main(String[] args) {
SpringApplication.run(StubRunnerBootEurekaExample.class, args);
}
}
我们想要启动一个 Stub Runner Boot 服务器 (),启用 Eureka 客户端 (),
并打开 Stub Runner 功能 ()。@EnableStubRunnerServer
@EnableEurekaClient
@AutoConfigureStubRunner
现在假设我们要启动此应用程序,以便自动注册存根。
为此,我们可以使用 运行应用程序,其中包含以下属性列表:java -jar ${SYSTEM_PROPS} stub-runner-boot-eureka-example.jar
${SYSTEM_PROPS}
* -Dstubrunner.repositoryRoot=https://repo.spring.io/snapshot (1)
* -Dstubrunner.cloud.stubbed.discovery.enabled=false (2)
* -Dstubrunner.ids=org.springframework.cloud.contract.verifier.stubs:loanIssuance,org.
* springframework.cloud.contract.verifier.stubs:fraudDetectionServer,org.springframework.
* cloud.contract.verifier.stubs:bootService (3)
* -Dstubrunner.idsToServiceIds.fraudDetectionServer=
* someNameThatShouldMapFraudDetectionServer (4)
*
* (1) - we tell Stub Runner where all the stubs reside (2) - we don't want the default
* behaviour where the discovery service is stubbed. That's why the stub registration will
* be picked (3) - we provide a list of stubs to download (4) - we provide a list of
这样,您部署的应用程序可以通过 service 向已启动的 WireMock 服务器发送请求
发现。点 1 到 3 很可能默认在 中设置,因为它们不是
可能会改变。这样,您可以只提供启动时要下载的存根列表
Stub Runner 靴子。application.yml
5.7. 消费者驱动的合约:每个消费者的存根
在某些情况下,同一终端节点的两个使用者希望有两个不同的响应。
此方法还可以让您立即知道哪个使用者使用您 API 的哪个部分。 您可以删除 API 生成的响应的一部分,并查看哪些自动生成的测试 失败。如果没有失败,您可以安全地删除响应的该部分,因为没有人使用它。 |
请考虑以下为生产者定义的 Contract 示例,该 Contract 名为 ,
它有两个使用者 ( 和 ):producer
foo-consumer
bar-consumer
foo-service
request {
url '/foo'
method GET()
}
response {
status OK()
body(
foo: "foo"
}
}
bar-service
request {
url '/bar'
method GET()
}
response {
status OK()
body(
bar: "bar"
}
}
您不能为同一请求生成两个不同的响应。这就是为什么你可以正确地打包
合同,然后从该功能中获利。stubsPerConsumer
在创建者端,使用者可以拥有一个文件夹,其中包含仅与他们相关的协定。
通过将标志设置为 ,我们不再注册所有存根,而只注册那些
对应于使用者应用程序的名称。换句话说,我们扫描每个存根的路径,然后,
如果它包含路径中具有使用者名称的子文件夹,则只有这样才会注册它。stubrunner.stubs-per-consumer
true
在生产者方面,合同将如下所示foo
.
└── contracts
├── bar-consumer
│ ├── bookReturnedForBar.groovy
│ └── shouldCallBar.groovy
└── foo-consumer
├── bookReturnedForFoo.groovy
└── shouldCallFoo.groovy
使用者可以将 或 设置为 或者,您可以按如下方式设置测试:bar-consumer
spring.application.name
stubrunner.consumer-name
bar-consumer
@ContextConfiguration(classes = Config, loader = SpringBootContextLoader)
@SpringBootTest(properties = ["spring.application.name=bar-consumer"])
@AutoConfigureStubRunner(ids = "org.springframework.cloud.contract.verifier.stubs:producerWithMultipleConsumers",
repositoryRoot = "classpath:m2repo/repository/",
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
stubsPerConsumer = true)
class StubRunnerStubsPerConsumerSpec extends Specification {
...
}
然后,仅允许引用在其名称中包含 path (即文件夹中的存根) 下注册的存根。bar-consumer
src/test/resources/contracts/bar-consumer/some/contracts/…
您还可以显式设置使用者名称,如下所示:
@ContextConfiguration(classes = Config, loader = SpringBootContextLoader)
@SpringBootTest
@AutoConfigureStubRunner(ids = "org.springframework.cloud.contract.verifier.stubs:producerWithMultipleConsumers",
repositoryRoot = "classpath:m2repo/repository/",
consumerName = "foo-consumer",
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
stubsPerConsumer = true)
class StubRunnerStubsPerConsumerWithConsumerNameSpec extends Specification {
...
}
然后,只允许在名称中包含 the 的路径下注册的存根 (即文件夹中的存根) 被引用。foo-consumer
src/test/resources/contracts/foo-consumer/some/contracts/…
有关详细信息,请参阅第 224 期 有关此更改背后的原因的信息。
5.8. 从某个位置获取存根或 Contract 定义
而不是从 Artifactory / Nexus 或 Git,人们可以只想指向 驱动器或 Classpath 上的位置。这在多模块项目中特别有用,其中一个模块需要 重用来自另一个模块的存根或 Contract 而不 需要在本地 maven 中实际安装这些 存储库或将这些更改提交到 Git。
为了实现这一点,在设置了 repository root 参数时,使用 protocol 就足够了
在 Stub Runner 或 Spring Cloud Contract 插件中。stubs://
在此示例中,项目已成功
built 并在该文件夹下生成存根。作为消费者,可以设置 Stub Runner 以使用协议从该位置选取存根。producer
target/stubs
stubs://
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "stubs://file://location/to/the/producer/target/stubs/",
ids = "com.example:some-producer")
@Rule
public StubRunnerRule rule = new StubRunnerRule()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/producer/target/stubs/")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE);
@RegisterExtension
public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/producer/target/stubs/")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE);
Contract 和 stub 可以存储在一个位置,每个 producer 都有自己的专用文件夹,用于 Contract 和 stub 映射。在该文件夹下,每个使用者都可以有自己的设置。要使 Stub Runner 从提供的 ID 中找到专用文件夹,可以传递 property 或 system property 。stubs.find-producer=true
stubrunner.stubs.find-producer=true
└── com.example (1)
├── some-artifact-id (2)
│ └── 0.0.1
│ ├── contracts (3)
│ │ └── shouldReturnStuffForArtifactId.groovy
│ └── mappings (4)
│ └── shouldReturnStuffForArtifactId.json
└── some-other-artifact-id (5)
├── contracts
│ └── shouldReturnStuffForOtherArtifactId.groovy
└── mappings
└── shouldReturnStuffForOtherArtifactId.json
1 | 消费者的 Group ID |
2 | 具有工件 ID [some-artifact-id] 的使用者 |
3 | 具有工件 ID [some-artifact-id] 的使用者的合同 |
4 | 具有工件 ID [some-artifact-id] 的使用者的映射 |
5 | 具有工件 ID [some-other-artifact-id] 的使用者 |
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "stubs://file://location/to/the/contracts/directory",
ids = "com.example:some-producer",
properties="stubs.find-producer=true")
static Map<String, String> contractProperties() {
Map<String, String> map = new HashMap<>();
map.put("stubs.find-producer", "true");
return map;
}
@Rule
public StubRunnerRule rule = new StubRunnerRule()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts/directory")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.properties(contractProperties());
static Map<String, String> contractProperties() {
Map<String, String> map = new HashMap<>();
map.put("stubs.find-producer", "true");
return map;
}
@RegisterExtension
public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts/directory")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.properties(contractProperties());
5.9. 在运行时生成存根
作为使用者,您可能不想等待创建者完成其实现,然后发布其存根。此问题的解决方案是在运行时生成存根。
作为创建者,在定义合同时,您需要使生成的测试通过,以便发布存根。在某些情况下,您希望取消阻止使用者,以便他们可以在测试实际通过之前获取存根。在这种情况下,您应该将此类合同设置为 in progress。您可以在 Contracts in Progress 部分阅读有关此内容的更多信息。这样,您的测试将不会生成,但会生成存根。
作为使用者,您可以切换开关以在运行时生成存根。Stub Runner 将忽略所有现有的存根映射,并将为所有合约定义生成新的存根映射。另一个选项是传递 system 属性。您可以在下面找到此类设置的示例。stubrunner.generate-stubs
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "stubs://file://location/to/the/contracts",
ids = "com.example:some-producer",
generateStubs = true)
@Rule
public StubRunnerRule rule = new StubRunnerRule()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.withGenerateStubs(true);
@RegisterExtension
public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.withGenerateStubs(true);
5.10. 无桩失败
默认情况下,如果未找到存根,则 Stub Runner 将失败。要更改该行为,只需在注解中设置为 property 或调用 JUnit Rule 或 Extension 上的方法。false
failOnNoStubs
withFailOnNoStubs(false)
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "stubs://file://location/to/the/contracts",
ids = "com.example:some-producer",
failOnNoStubs = false)
@Rule
public StubRunnerRule rule = new StubRunnerRule()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.withFailOnNoStubs(false);
@RegisterExtension
public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
.downloadStub("com.example:some-producer")
.repoRoot("stubs://file://location/to/the/contracts")
.stubsMode(StubRunnerProperties.StubsMode.REMOTE)
.withFailOnNoStubs(false);
5.11. 通用属性
本节简要介绍常见的属性,包括:
5.11.1. JUnit 和 Spring 的通用属性
您可以使用系统属性或 Spring 配置来设置重复属性 性能。下表显示了它们的名称及其默认值:
属性名称 | 默认值 | 描述 |
---|---|---|
stubrunner.minPort |
10000 |
带有存根的已启动 WireMock 的端口最小值。 |
stubrunner.maxPort |
15000 |
带有存根的已启动 WireMock 的端口的最大值。 |
stubrunner.repository根 |
Maven 存储库 URL。如果为空,则调用本地 Maven 存储库。 |
|
stubrunner.classifier 中 |
存根 |
存根工件的默认分类器。 |
stubrunner.stubs模式 |
类路径 |
你想要获取和注册存根的方式 |
stubrunner.ids 文件 |
要下载的 Ivy 表示法存根数组。 |
|
stubrunner.username |
可选的 username 来访问存储 JAR 的工具 存根。 |
|
stubrunner.password |
用于访问存储 JAR 的工具的可选密码 存根。 |
|
stubrunner.stubsPerConsumer |
|
如果要对
每个使用者,而不是为每个使用者注册所有存根。 |
stubrunner.consumer名称 |
如果您想为每个使用者使用一个存根,并且希望 覆盖使用者名称,更改此值。 |
5.11.2. Stub Runner 存根 ID
您可以在 system 属性中将存根设置为 download。他们
使用以下模式:stubrunner.ids
groupId:artifactId:version:classifier:port
请注意,、 和 是可选的。version
classifier
port
-
如果您不提供 ,则会随机选择一个。
port
-
如果未提供 ,则使用默认值。(请注意,您可以 以这种方式传递一个空分类器:)。
classifier
groupId:artifactId:version:
-
如果未提供 ,则传递 ,最新的是 下载。
version
+
port
表示 WireMock 服务器的端口。
从版本 1.0.4 开始,您可以提供一系列版本,您可以 希望 Stub Runner 考虑在内。您可以阅读有关 Aether 版本控制的更多信息 范围。 |
6. Spring Cloud 合约 WireMock
Spring Cloud Contract WireMock 模块允许您在 Spring Boot 应用程序。查看示例了解更多详情。
如果您有一个使用 Tomcat 作为嵌入式服务器的 Spring Boot 应用程序(即
默认值为 ),你可以添加到 Classpath 中,并添加以在测试中使用 Wiremock。Wiremock 作为存根服务器运行,而您
可以通过使用 Java API 或使用静态 JSON 声明作为
你的测试。下面的代码显示了一个示例:spring-boot-starter-web
spring-cloud-starter-contract-stub-runner
@AutoConfigureWireMock
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
public class WiremockForDocsTests {
// A service that calls out over HTTP
@Autowired
private Service service;
@Before
public void setup() {
this.service.setBase("http://localhost:"
+ this.environment.getProperty("wiremock.server.port"));
}
// Using the WireMock APIs in the normal way:
@Test
public void contextLoads() throws Exception {
// Stubbing WireMock
stubFor(get(urlEqualTo("/resource")).willReturn(aResponse()
.withHeader("Content-Type", "text/plain").withBody("Hello World!")));
// We're asserting if WireMock responded properly
assertThat(this.service.go()).isEqualTo("Hello World!");
}
}
要在其他端口上启动存根服务器,请使用 (例如)、.对于随机端口,请使用值 。存根
服务器端口可以在测试应用程序上下文中使用 “wiremock.server.port” 绑定
财产。Using 将
测试应用程序上下文,缓存在方法和类之间
具有相同的上下文。Spring 集成测试也是如此。此外,您还可以
将 type 的 bean 注入到测试中。
注册的 WireMock 服务器在每个测试类之后重置,但是,如果需要在每个测试方法之后重置它,只需将属性设置为 .@AutoConfigureWireMock(port=9999)
0
@AutoConfigureWireMock
WiremockConfiguration
WireMockServer
wiremock.reset-mappings-after-each-test
true
6.1. 自动注册存根
如果使用 ,它会从文件中注册 WireMock JSON 存根
system 或 classpath (默认情况下,从 )。您可以
使用注释中的属性自定义位置,该属性可以是
Ant 样式的资源模式或目录。对于目录,是
附加。下面的代码显示了一个示例:@AutoConfigureWireMock
file:src/test/resources/mappings
stubs
*/.json
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureWireMock(stubs="classpath:/stubs") public class WiremockImportApplicationTests { @Autowired private Service service; @Test public void contextLoads() throws Exception { assertThat(this.service.go()).isEqualTo("Hello World!"); } }
实际上,WireMock 总是从
以及属性中的自定义位置。要更改此行为,您可以
此外,指定 Files 根,如本文档的下一节所述。src/test/resources/mappings stubs |
此外,该位置的映射不被视为 Wiremock 的“默认映射”的一部分,并调用
to 不会导致映射
在所包含的位置。但是,它会在每个测试类之后重置映射(包括从 stubs 位置添加映射),并且(可选)会重置
在每个测试方法之后(由属性保护)。stubs com.github.tomakehurst.wiremock.client.WireMock.resetToDefaultMappings stubs org.springframework.cloud.contract.wiremock.WireMockTestExecutionListener wiremock.reset-mappings-after-each-test |
如果您使用 Spring Cloud Contract 的默认存根 jar,则您的
存根存储在文件夹中。
如果要从该位置注册所有嵌入式 JAR 中的所有存根,可以使用
以下语法:/META-INF/group-id/artifact-id/versions/mappings/
@AutoConfigureWireMock(port = 0, stubs = "classpath*:/META-INF/**/mappings/**/*.json")
6.2. 使用文件指定存根体
WireMock 可以从 Classpath 或文件系统上的文件中读取响应体。在
的情况下,您可以在 JSON DSL 中看到响应具有 a 而不是
(文字) 。文件是相对于根目录解析的(默认情况下为 )。要自定义此位置,可以将 annotation 中的属性设置为父项的位置
directory (换句话说,是一个子目录)。您可以使用 Spring 资源
表示法 (notation) 来引用或位置。通用 URL 不是
支持。可以给出值列表 — 在这种情况下,WireMock 解析第一个文件
当它需要查找响应正文时,它就存在了。bodyFileName
body
src/test/resources/__files
files
@AutoConfigureWireMock
__files
file:…
classpath:…
配置根时,它还会影响
自动加载存根,因为它们来自根位置
在名为 .的值没有
对从属性显式加载的存根的影响。files mappings files stubs |
6.3. 替代方案:使用 JUnit 规则
对于更传统的 WireMock 体验,您可以使用 JUnit 来启动和停止
服务器。为此,请使用 convenience 类获取实例,如下例所示:@Rules
WireMockSpring
Options
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class WiremockForDocsClassRuleTests {
// Start WireMock on some dynamic port
// for some reason `dynamicPort()` is not working properly
@ClassRule
public static WireMockClassRule wiremock = new WireMockClassRule(
WireMockSpring.options().dynamicPort());
// A service that calls out over HTTP to wiremock's port
@Autowired
private Service service;
@Before
public void setup() {
this.service.setBase("http://localhost:" + wiremock.port());
}
// Using the WireMock APIs in the normal way:
@Test
public void contextLoads() throws Exception {
// Stubbing WireMock
wiremock.stubFor(get(urlEqualTo("/resource")).willReturn(aResponse()
.withHeader("Content-Type", "text/plain").withBody("Hello World!")));
// We're asserting if WireMock responded properly
assertThat(this.service.go()).isEqualTo("Hello World!");
}
}
这意味着服务器在完成此类中的所有方法后关闭
已运行。@ClassRule
6.4. Rest 模板的宽松 SSL 验证
WireMock 允许您使用 URL 协议对 “安全” 服务器进行存根。如果您的
应用程序想要在集成测试中联系该存根服务器,它会发现
SSL 证书无效(自行安装的证书的常见问题)。
最好的选择通常是重新配置客户端以使用 .如果那不是
选项,你可以要求 Spring 配置一个忽略 SSL 验证错误的 HTTP 客户端
(当然,仅对测试执行此操作)。https
http
要使这项工作最小化,您需要在应用程序中使用 Spring Boot,如下例所示:RestTemplateBuilder
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
你需要,因为构建器是通过回调传递给
初始化它,以便可以在此时在客户端中设置 SSL 验证。这
如果您使用 Annotation 或 Stub Runner,则会自动在测试中发生。如果使用 JUnit 方法,则还需要添加注释,如下例所示:RestTemplateBuilder
@AutoConfigureWireMock
@Rule
@AutoConfigureHttpClient
@RunWith(SpringRunner.class)
@SpringBootTest("app.baseUrl=https://localhost:6443")
@AutoConfigureHttpClient
public class WiremockHttpsServerApplicationTests {
@ClassRule
public static WireMockClassRule wiremock = new WireMockClassRule(
WireMockSpring.options().httpsPort(6443));
...
}
如果使用 ,则 Apache HTTP 客户端位于
classpath 的 API 路径,它由 选择并配置为忽略 SSL
错误。如果使用默认客户端,则不需要 Annotation(但它
没有坏处)。目前不支持其他客户端,但可能会添加
在将来的版本中。spring-boot-starter-test
RestTemplateBuilder
java.net
要禁用自定义 ,请将属性设置为 。RestTemplateBuilder
wiremock.rest-template-ssl-enabled
false
6.5. WireMock 和 Spring MVC 模拟
Spring Cloud Contract 提供了一个方便的类,可以将 JSON WireMock 存根加载到
一个弹簧 .下面的代码显示了一个示例:MockRestServiceServer
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
public class WiremockForDocsMockServerApplicationTests {
@Autowired
private RestTemplate restTemplate;
@Autowired
private Service service;
@Test
public void contextLoads() throws Exception {
// will read stubs classpath
MockRestServiceServer server = WireMockRestServiceServer.with(this.restTemplate)
.baseUrl("https://example.org").stubs("classpath:/stubs/resource.json")
.build();
// We're asserting if WireMock responded properly
assertThat(this.service.go()).isEqualTo("Hello World");
server.verify();
}
}
该值附加到所有 mock 调用的前面,并且该方法采用存根
path 资源模式作为参数。在前面的示例中,定义的存根 at 被加载到模拟服务器中。如果要求
访问 ,它会获取在该 URL 中声明的响应。更多
可以指定一个存根模式,并且每个存根模式可以是一个目录(对于递归的
list of all )、固定文件名(如前面的示例所示)或 Ant 样式
模式。JSON 格式是普通的 WireMock 格式,您可以在 WireMock 网站上阅读有关该格式的信息。baseUrl
stubs()
/stubs/resource.json
RestTemplate
example.org/
.json
目前,Spring Cloud Contract Verifier 支持 Tomcat、Jetty 和 Undertow 作为 Spring Boot 嵌入式服务器,而 Wiremock 本身对特定的 Jetty 版本(当前为 9.2)。要使用本机 Jetty,您需要将本机 Wiremock 依赖项并排除 Spring Boot 容器(如果有)。
8. 下一步要读什么
如果您对 Spring Cloud Contract 的核心功能感到满意,则可以继续阅读 关于 Spring Cloud Contract 的高级功能。