Spring Cloud 合约功能
1. 合约 DSL
Spring Cloud Contract 支持用以下语言编写的 DSL:
-
槽的
-
YAML
-
Java
-
Kotlin
Spring Cloud 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 文件中使用 Java 语法。
如果您决定在 Groovy 中编写合同,如果您还没有使用 Groovy,请不要惊慌 以前。 实际上并不需要该语言的知识,因为合约 DSL 只使用它的一小部分(仅文字、方法调用和闭包)。此外,DSL 是静态的类型化的,以使其程序员无需了解 DSL 本身即可阅读。
请记住,在 Groovy 合约文件中,您必须提供完整的限定名称Contract class 和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>>
对于多个合同。
您还可以在src/test/java
(例如src/test/java/contracts
),这样您就不必修改项目的类路径。在这种情况下,您必须向 Spring Cloud Contract 插件提供契约定义的新位置。
<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 一样,您可以将合约放在您选择的任何目录中。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 脚本文件中,您必须向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 数组大小的支持是实验性的。 如果需要帮助, 要打开它,请将以下系统属性的值设置为true :spring.cloud.contract.verifier.assert.size . 默认情况下,此功能设置为false . 您还可以将assertJsonSize 插件配置中的属性。 |
因为 JSON 结构可以有任何形式,所以不可能解析它正确使用 Groovy DSL 和value(consumer(…), producer(…)) 符号GString . 那 这就是为什么您应该使用 Groovy Map 表示法。 |
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. 名称
您可以为合同提供名称。假设您提供了以下名称:should register a user
.如果这样做,则自动生成的测试的名称为validate_should_register_a_user
.此外,WireMock 存根中的存根名称为should_register_a_user.json
.
您必须确保名称不包含任何使 生成的测试未编译。另外,请记住,如果您为 多个合约,自动生成的测试无法编译,生成的存根 相互覆盖。 |
以下示例演示如何向合约添加名称:
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. 忽略合约
如果要忽略合约,可以在
插件配置或将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
}
您可以设置failOnInProgress
Spring Cloud Contract 插件属性,以确保当源代码中至少保留一个正在进行的合约时,您的构建将中断。
1.6.5. 从文件传递值
从版本开始1.2.0
,您可以从文件中传递值。假设您拥有
项目中的以下资源:
└── src
└── test
└── resources
└── contracts
├── readFromFile.groovy
├── request.json
└── response.json
进一步假设您的合同如下:
/*
* 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"
}
当测试或存根生成时,request.json
和response.json
文件传递给正文
请求或响应。文件名必须是带有位置的文件
相对于合同所在的文件夹。
如果您需要以二进制形式传递文件的内容,
您可以使用fileAsBytes
编码 DSL 中的方法或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 验证,对于与
标准request
作为合约的一部分,服务器提供位于
与response
合同的一部分。随后,合同用于
生成 WireMock 存根,对于与所提供条件匹配的任何请求,提供
合适的回应。
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
}
如果你想让你的合同有更高的优先级,
您需要将较低的数字传递给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)
}
}
您可以指定绝对值而不是相对值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
方法/部分,如以下示例所示:
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(…))
). -
通过使用
named(…)
方法,用于设置命名参数。命名参数 可以设置一个name
和content
.您可以使用具有两个参数的方法调用它 如named("fileName", "fileContent")
,或使用映射表示法,例如named(name: "fileName", content: "fileContent")
.
-
多部分参数在
multipart.params
部分。 -
命名参数(
fileName
和fileContent
对于给定的参数名称) 可以在multipart.named
部分。该部分包含 这paramName
(参数名称),fileName
(文件名),fileContent
(文件的内容)字段。 -
动态位可以通过
matchers.multipart
部分。-
对于参数,请使用
params
部分,可以接受regex
或predefined
正则表达式。 -
对于命名参数,请使用
named
首先你 定义参数名称paramName
.然后你可以传递 参数化fileName
或fileContent
在regex
或在predefined
正则表达式。
-
从前面示例中的协定中,生成的测试和存根如下所示:
// 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
}
}
除了状态之外,响应还可以包含标头、cookie 和正文,它们是 指定方式与请求中相同(请参阅 HTTP 请求)。
在 Groovy DSL 中,您可以引用org.springframework.cloud.contract.spec.internal.HttpStatus 方法来提供有意义的状态而不是数字。例如,您可以调用OK() 对于状态200 或BAD_REQUEST() 为400 . |
2.4. 动态属性
合约可以包含一些动态属性:时间戳、ID 等。您不会 想要强制消费者存根他们的时钟以始终返回相同的时间值 以便它与存根匹配。
对于 Groovy DSL,您可以在合约中提供动态部分
有两种方式:将它们直接传递到正文中或将它们设置在名为bodyMatchers
.
在 2.0.0 之前,这些是通过使用testMatchers 和stubMatchers .
有关详细信息,请参阅迁移指南。 |
对于 YAML,您只能使用matchers
部分。
内的条目matchers 必须引用有效负载的现有元素。有关详细信息,请检查此问题。 |
2.4.1. Body 内部的动态属性
本节仅对编码 DSL(Groovy、Java 等)有效。查看“匹配器部分”部分中的动态属性,了解类似功能的 YAML 示例。 |
您可以使用value
方法,或者,如果您使用
Groovy 映射表示法,使用 .以下示例演示如何设置动态
属性,并采用 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 中编写您的请求。这样做是 当您想要指示应提供给定的响应时特别有用 对于遵循给定模式的请求。此外,您可以在以下情况下使用正则表达式 需要对测试和服务器端测试使用模式而不是精确值。
确保正则表达式与序列的整个区域匹配,就像在内部调用一样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")
}
}
}
在前面的示例中,通信的另一端具有相应的数据 为请求和响应生成。
Spring Cloud 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();
}
在您的合约中,您可以按如下方式使用它(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]'))}]"
)
}
}
为了让事情变得更简单,您可以使用一组自动
假设您希望传递正则表达式。
所有这些方法都以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 端
-
响应的 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")))
))
}
}
通过将身体的一部分包裹起来optional()
方法,您创建一个正则表达式,该表达式必须出现 0 次或多次。
如果使用 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 示例。 |
您可以定义在测试期间在服务器端运行的方法调用。这样的
方法可以添加到定义为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
}
}
不能同时使用String 和execute 以执行串联。为
示例,调用header('Authorization', 'Bearer ' + execute('authToken()')) 导致
结果不当。相反,请调用header('Authorization', execute('authToken()')) 和
确保authToken() 方法返回您需要的一切。 |
从 JSON 读取的对象的类型可以是以下类型之一,具体取决于 JSON 路径:
-
String
:如果指向String
JSON 中的值。 -
JSONArray
:如果指向List
在 JSON 中。 -
Map
:如果指向Map
在 JSON 中。 -
Number
:如果指向Integer
,Double
,以及 JSON 中的其他数字类型。 -
Boolean
:如果指向Boolean
在 JSON 中。
在合同的请求部分,您可以指定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 在您的响应中。
如果您在 Groovy DSL 中编写合约,则可以使用fromRequest()
方法,这允许
您引用了 HTTP 请求中的一堆元素。您可以使用以下
选项:
-
fromRequest().url()
:返回请求 URL 和查询参数。 -
fromRequest().query(String key)
:返回第一个具有给定名称的查询参数。 -
fromRequest().query(String key, int index)
:返回第 n 个查询参数,其中包含 名。 -
fromRequest().path()
:返回完整路径。 -
fromRequest().path(int index)
:返回第 n 个路径元素。 -
fromRequest().header(String key)
:返回具有给定名称的第一个标头。 -
fromRequest().header(String key, int index)
:返回具有给定名称的第 n 个标头。 -
fromRequest().body()
:返回完整的请求正文。 -
fromRequest().body(String jsonPath)
:从请求中返回元素 与 JSON 路径匹配。
如果您使用 YAML 合约定义或 Java 合约定义,则必须将 Handlebars 表示法与自定义 Spring Cloud 合约一起使用
函数来实现这一点。在这种情况下,您可以使用以下选项:{{{ }}}
-
{{{ request.url }}}
:返回请求 URL 和查询参数。 -
{{{ request.query.key.[index] }}}
:返回具有给定名称的第 n 个查询参数。 例如,对于thing
,第一个条目是{{{ request.query.thing.[0] }}}
-
{{{ request.path }}}
:返回完整路径。 -
{{{ request.path.[index] }}}
:返回第 n 个路径元素。例如 第一个条目是 {{{ 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 的response-template 响应转换器。它使用 Handlebars 将 Mustache 模板转换为
适当的值。此外,它还注册了两个辅助函数:{{{ }}} |
-
escapejsonbody
:以可嵌入 JSON 的格式转义请求正文。 -
jsonpath
:对于给定参数,在请求正文中查找一个对象。
2.4.6. 匹配器部分中的动态属性
如果您与 Pact 合作,以下讨论可能看起来很熟悉。 相当多的用户习惯于在主体之间进行分离并将 合约的动态部分。
您可以使用bodyMatchers
部分有两个原因:
-
定义应最终出现在存根中的动态值。 您可以在
request
或inputMessage
合同的一部分。 -
验证测试结果。 此部分位于
response
或outputMessage
的 合同。
目前,Spring Cloud Contract Verifier 仅支持具有 以下匹配可能性:
编码 DSL
-
对于存根(在消费者端的测试中):
-
byEquality()
:从提供的 JSON 路径中的使用者请求中获取的值必须是 等于合同中规定的价值。 -
byRegex(…)
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配正则表达式。您还可以传递预期匹配值的类型(例如,asString()
,asLong()
,依此类推)。 -
byDate()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO 日期值的正则表达式。 -
byTimestamp()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO DateTime 值的正则表达式。 -
byTime()
:从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO 时间值的正则表达式。
-
-
对于验证(在生产者端生成的测试中):
-
byEquality()
:从提供的 JSON 路径中生产者的响应中获取的值必须是 等于合同中提供的价值。 -
byRegex(…)
:从提供的 JSON 路径中生产者的响应中获取的值必须 匹配正则表达式。 -
byDate()
:从提供的 JSON 路径中生产者的响应中获取的值必须匹配 ISO 日期值的正则表达式。 -
byTimestamp()
:从提供的 JSON 路径中生产者的响应中获取的值必须 匹配 ISO DateTime 值的正则表达式。 -
byTime()
:从提供的 JSON 路径中生产者的响应中获取的值必须匹配 ISO 时间值的正则表达式。 -
byType()
:从提供的 JSON 路径中生产者的响应中获取的值需要为 与合同中响应正文中定义的类型相同。byType
可以采用闭包,您可以在其中设置minOccurrence
和maxOccurrence
.对于 请求端,您应该使用闭包来断言集合的大小。 这样,就可以断言展平集合的大小。检查 unflattened 集合,请使用自定义方法和byCommand(…)
testMatcher
. -
byCommand(…)
:从提供的 JSON 路径中生产者的响应中获取的值为 作为输入传递给您提供的自定义方法。例如byCommand('thing($it)')
调用thing
与 JSON 路径被传递。从 JSON 读取的对象的类型可以是 以下内容,具体取决于 JSON 路径:-
String
:如果指向String
价值。 -
JSONArray
:如果指向List
. -
Map
:如果指向Map
. -
Number
:如果指向Integer
,Double
,或其他类型的数字。 -
Boolean
:如果指向Boolean
.
-
-
byNull()
:从提供的 JSON 路径中的响应中获取的值必须为 null。
-
YAML
有关 Groovy 的详细说明,请参阅 Groovy 部分 类型意味着什么。 |
对于 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
在前面的示例中,您可以在matchers
部分。对于请求部分,您可以看到,对于所有字段,但valueWithoutAMatcher
,存根应包含的正则表达式的值
contain 被显式设置。对于valueWithoutAMatcher
,则进行验证
与不使用匹配器的方式相同。在这种情况下,测试将执行
相等性检查。
对于响应端bodyMatchers
部分,我们在
类似的方式。唯一的区别是byType
匹配者也在场。这
验证器引擎检查四个字段,以验证测试的响应是否
具有 JSON 路径与给定字段匹配的值,与
定义,并通过以下检查(基于被调用的方法):
-
为
$.valueWithTypeMatch
,发动机检查类型是否相同。 -
为
$.valueWithMin
,引擎检查类型并断言大小是否更大 大于或等于最小出现次数。 -
为
$.valueWithMax
,引擎检查类型并断言大小是否为 小于或等于最大出现次数。 -
为
$.valueWithMinMax
,引擎检查类型并断言大小是否为 在最小和最大出现次数之间。
生成的测试类似于以下示例(请注意,一个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");
请注意,对于byCommand 方法,则示例调用assertThatValueIsANumber .此方法必须在测试基类中定义,或者
静态导入到测试中。请注意,byCommand call 已转换为assertThatValueIsANumber(parsedJson.read("$.duck")); .这意味着发动机采取了
方法名称,并将正确的 JSON 路径作为参数传递给它。 |
生成的 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" ]
}
}
'''
如果您使用matcher ,请求和响应的部分matcher 地址将从 断言中删除 JSON 路径。在以下情况下
验证集合时,必须为
收集。 |
请考虑以下示例:
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('.+'))
}
}
}
前面的代码导致创建以下测试(代码块仅显示断言部分):
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. 异步支持
如果您在服务器端使用异步通信(您的控制器是
返回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
// ...
}
}
您还可以使用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 正文必须在body
元素
作为String
或GString
.此外,还可以提供身体匹配器
请求和响应。代替jsonPath(…)
方法,则org.springframework.cloud.contract.spec.internal.BodyMatchers.xPath
应使用方法,并具有所需的xPath
作为第一个参数提供
和适当的MatchingType
作为第二。除了byType()
被支持。
以下示例显示了响应正文中带有 XML 的 Groovy DSL 协定:
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 {
// ...
}
}
在前面的示例中,一个合约具有name
字段,另一个则没有。这
导致生成两个测试,它们看起来或多或少如下:
package org.springframework.cloud.contract.verifier.tests.com.hello;
import com.example.TestBase;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.jayway.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import com.jayway.restassured.response.ResponseOptions;
import org.junit.Test;
import static com.jayway.restassured.module.mockmvc.RestAssuredMockMvc.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
public class V1Test extends TestBase {
@Test
public void validate_should_post_a_user() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.post("/users/1");
// then:
assertThat(response.statusCode()).isEqualTo(200);
}
@Test
public void validate_withList_1() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.post("/users/2");
// then:
assertThat(response.statusCode()).isEqualTo(200);
}
}
请注意,对于具有name
字段,生成的测试方法名为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. 有状态合约
有状态协定(也称为方案)是应读取的协定定义 挨次。 这在以下情况下可能很有用:
-
您希望以精确定义的顺序执行合约,因为您使用 SpringCloud Contract 来测试您的有状态应用程序
我们真的不鼓励你这样做,因为合约测试应该是无状态的。 |
-
您希望同一终结点为同一请求返回不同的结果。
若要创建有状态合约(或方案),需要 在创建合同时使用正确的命名约定。大会 需要包括订单号,后跟下划线。无论如何,这都有效 无论您是使用 YAML 还是 Groovy。以下列表显示了一个示例:
my_contracts_dir\
scenario1\
1_login.groovy
2_showCart.groovy
3_logout.groovy
这样的树导致 Spring Cloud Contract Verifier 生成 WireMock 的场景,其中包含
名称scenario1
以及以下三个步骤:
-
login,标记为
Started
指向... -
showCart,标记为
Step1
指向... -
注销,标记为
Step2
(这结束了场景)。
可以在 https://wiremock.org/docs/stateful-behaviour/ 找到有关 WireMock 方案的更多详细信息。
3. 集成
3.1. JAX-RS
Spring Cloud Contract 支持 JAX-RS 2 客户端 API。基类需要
定义protected WebTarget webTarget
和服务器初始化。唯一的选择
测试 JAX-RS API 是启动一个 Web 服务器。此外,带有正文的请求需要有一个
内容类型。否则,默认值为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. WebFlux 与 WebTestClient
您可以使用 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 基类和 RestAssured 对于 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 mode 比EXPLICIT 模式。 |
3.3. 具有显式模式的 WebFlux
您还可以在生成的测试中将 WebFlux 与显式模式一起使用 与 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 支持上下文路径。
完全支持上下文路径所需的唯一更改是
制片人方面。此外,自动生成的测试必须使用显式模式。消费者
侧面保持不变。为了使生成的测试通过,您必须使用 explic
模式。以下示例演示如何将测试模式设置为 专家
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 的 HTTP API,WebTestClient
,或 RestAssured。在为 API 生成文档的同时,您还可以
使用 Spring Cloud Contract WireMock 生成 WireMock 存根。为此,请将您的
普通 REST Docs 测试用例和使用@AutoConfigureRestDocs
要有存根
在 REST Docs 输出目录中自动生成。

以下示例使用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"));
}
}
此测试在target/snippets/stubs/resource.json
.它匹配
都GET
请求/resource
路径。相同的示例WebTestClient
(用过
用于测试 Spring WebFlux 应用程序)将如下所示:
@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 方法和除host
和content-length
.要匹配
更精确地请求(例如,为了匹配 POST 或 PUT 的正文),我们需要
显式创建请求匹配器。这样做有两个效果:
-
创建仅以您指定的方式匹配的存根。
-
断言测试用例中的请求也匹配相同的条件。
此功能的主要入口点是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"));
}
}
前面的合约指定任何具有id
字段接收响应
在此测试中定义。您可以将调用链接在一起.jsonPath()
添加其他
匹配器。如果 JSON 路径不熟悉,则 JayWay
文档可以帮助您快速上手。这WebTestClient
此测试的版本
有类似的verify()
插入同一位置的静态帮助程序。
而不是jsonPath
和contentType
方便的方法,也可以使用
WireMock API 来验证请求是否与创建的存根匹配,作为
以下示例显示:
@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 很丰富。您可以通过以下方式匹配标头、查询参数和请求正文 正则表达式以及 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"
}
}
}
您可以使用wiremock() 方法或jsonPath() 和contentType() 方法来创建请求匹配器,但不能同时使用这两种方法。 |
在消费者方面,您可以将resource.json
在本节前面生成
在类路径上可用(例如,通过将存根发布为 JAR)。之后,您可以创建一个在
多种不同的方式,包括使用@AutoConfigureWireMock(stubs="classpath:resource.json")
,如本文前面所述
公文。
3.5.1. 使用 REST 文档生成合约
您还可以使用 Spring REST 生成 Spring Cloud Contract DSL 文件和文档 文档。如果与 Spring Cloud WireMock 结合使用,则会同时获得两个合约 和存根。
为什么要使用此功能?社区有人提问 关于他们希望转向基于 DSL 的合同定义的情况, 但他们已经有很多 Spring MVC 测试。使用此功能可以生成 您稍后可以修改并移动到文件夹(在 configuration),以便插件找到它们。
您可能想知道为什么此功能位于 WireMock 模块中。功能 是因为同时生成合约和存根是有意义的。 |
考虑以下测试:
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()));
前面的测试创建了上一节中介绍的存根,并生成了 合同和文档文件。
合约称为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. 由方法触发的输出
可以通过调用方法(例如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
在前面的示例案例中,输出消息被发送到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
在前面的示例中,输出消息被发送到output
如果正确的消息是
在input
目的地。在消息发布者端,引擎
生成一个测试,将输入消息发送到定义的目标。在
消费者方面,您可以向输入目的地发送消息或使用标签
(some_label
在示例中)触发消息。
4.1.3. 消费者/生产者
此部分仅对 Groovy DSL 有效。 |
在 HTTP 中,你有一个概念client
/stub and `server
/test
表示法。您还可以
在消息传递中使用这些范式。此外,Spring Cloud Contract Verifier 还
提供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. 普通
在input
或outputMessage
部分,您可以调用assertThat
与名称
的method
(例如,assertThatMessageIsOnTheQueue()
) 您在
基类或静态导入。Spring Cloud Contract 运行该方法
在生成的测试中。
4.2. 集成
您可以使用以下四种集成配置之一:
-
阿帕奇骆驼
-
Spring 集成
-
Spring Cloud Stream
-
Spring AMQP
-
Spring JMS(需要嵌入式代理)
-
Spring Kafka(需要嵌入式代理)
由于我们使用 Spring Boot,如果您已将其中一个库添加到类路径中,则所有 消息传递配置将自动设置。
记得把@AutoConfigureMessageVerifier 在
生成的测试。否则,Spring Cloud Contract 的消息传递部分不会
工作。 |
如果要使用 Spring Cloud Stream,请记住添加一个依赖 专家
Gradle
|
4.2.1. 手动集成测试
测试使用的主要界面是org.springframework.cloud.contract.verifier.messaging.MessageVerifier
.
它定义了如何发送和接收消息。您可以创建自己的实现来
实现相同的目标。
在测试中,您可以注入一个ContractVerifierMessageExchange
发送和接收合同后面的消息。然后添加@AutoConfigureMessageVerifier
到你的测试中。以下示例显示了如何执行此作:
@RunWith(SpringTestRunner.class)
@SpringBootTest
@AutoConfigureMessageVerifier
public static class MessagingContractTests {
@Autowired
private MessageVerifier verifier;
...
}
如果您的测试也需要存根,则@AutoConfigureStubRunner 包括消息传递配置,因此您只需要一个注释。 |
4.3. 生产者端消息传递测试生成
拥有input
或outputMessage
部分会导致创建测试在发布者方面。默认情况下,会创建 JUnit 4 测试。但是,还有一个可以创建 JUnit 5、TestNG 或 Spock 测试。
我们应该考虑三种主要情况:
-
场景 1:没有生成输出消息的输入消息。输出消息由应用程序内部的组件(例如,调度程序)触发。
-
场景 2:输入消息触发输出消息。
-
场景三:输入消息被消耗,没有输出消息。
传递给messageFrom 或sentTo 可以有不同的不同消息传递实现的含义。对于流和集成,它是首先解析为destination 通道的。那么,如果没有这样的destination 它被解析为通道名称。对于 Camel,这是一个特定的组件(例如,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
对于前面的合同,将创建以下测试:
'''\
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()
对于前面的合同,将创建以下测试:
"""\
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. 消费者存根生成
与 HTTP 部分不同,在消息传递中,我们需要在 JAR 中发布合约定义,并使用 存根。然后在消费者端对其进行解析,并创建适当的存根路由。
如果类路径上有多个框架,则存根运行程序需要
定义应该使用哪一个。假设您有 AMQP、Spring Cloud Stream 和 Spring Integration
在类路径上,并且您想要使用 Spring AMQP。然后你需要设置stubrunner.stream.enabled=false 和stubrunner.integration.enabled=false .
这样,唯一剩下的框架就是 Spring AMQP。 |
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();
}
为方便起见,该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 添加到项目中
您可以在类路径上同时使用 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
destination,如下所示:
producerTemplate.
sendBodyAndHeaders('jms:delete', new BookReturned('foo'), [sample: 'header'])
4.6. 使用 Spring Integration 的消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring Integration 集成。对于提供的项目,它会自动下载 存根并注册所需的路由。
4.6.1. 将运行器添加到项目中
您可以在
类路径。请记住使用@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 Integration 路线:
<?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')
以下列表显示了如何侦听发送到jms:output
:
Message<?> receivedMessage = messaging.receive('outputTest')
收到的消息将传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 2(输入触发的输出)
由于路线是为您设置的,您可以向jms:output
destination,如下所示:
messaging.send(new BookReturned('foo'), [sample: 'header'], 'input')
以下列表显示了如何侦听发送到jms:output
:
Message<?> receivedMessage = messaging.receive('outputTest')
收到的消息传递以下断言:
receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)
由于路线是为您设置的,您可以向jms:input
destination,如下所示:
messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')
4.7. 使用 Spring Cloud Stream 的消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring Stream 集成。对于提供的工件,它会自动下载 存根并注册所需的路由。
如果存根运行器与流的集成messageFrom 或sentTo 字符串
首先解析为destination 频道的,没有这样的destination 存在,则
destination 被解析为通道名称。 |
如果要使用 Spring Cloud Stream,请记住添加一个依赖 专家
Gradle
|
4.7.1. 将运行器添加到项目中
您可以在
类路径。请记住使用@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
interface 作为
遵循:
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
destination,如下所示:
messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')
4.8. 使用 Spring AMQP 的消费者端消息传递
Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring AMQP 的 Rabbit 模板集成。对于提供的项目,它 自动下载存根并注册所需的路由。
集成尝试独立工作(即,不与正在运行的
RabbitMQ 消息代理)。它期望RabbitTemplate
在应用程序上下文和
将其用作名为@SpyBean
.因此,它可以使用 Mockito 间谍
用于验证和检查应用程序发送的消息的功能。
在消息使用者端,存根运行器将所有@RabbitListener
注释
端点和所有SimpleMessageListenerContainer
应用程序上下文中的对象。
由于消息通常发送到 AMQP 中的交换,因此消息合约包含 交易所名称作为目标。另一端的消息监听器绑定到 队列。绑定将交换连接到队列。如果触发了消息协定,则 Spring AMQP 存根运行器集成在应用程序上下文上查找绑定,该绑定 匹配此交换。然后它从 Spring 交换中收集队列并尝试 查找绑定到这些队列的消息侦听器。为所有匹配触发消息 消息监听器。
如果您需要使用路由键,可以使用amqp_receivedRoutingKey
messaging 标头。
4.8.1. 将运行器添加到项目中
您可以在类路径上同时拥有 Spring AMQP 和 Spring Cloud Contract Stub Runner,并且
设置属性stubrunner.amqp.enabled=true
.记得注释你的测试类
跟@AutoConfigureStubRunner
.
如果类路径上已经有 Stream 和 Integration,则需要
通过设置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
interface 作为
遵循:
stubTrigger.trigger("contract-test.person.created.event")
消息的目的地为contract-test.exchange
,因此 Spring AMQP 存根运行器
集成查找与此交换相关的绑定,如以下示例所示:
@Bean
public Binding binding() {
return BindingBuilder.bind(new Queue("test.queue"))
.to(new DirectExchange("contract-test.exchange")).with("#");
}
绑定定义绑定名为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 在我们的测试期间尝试连接到正在运行的代理,我们
配置模拟ConnectionFactory
.
禁用模拟的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. 将运行器添加到项目中
您需要在类路径上同时具有 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
destination,如下所示:
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. 将运行器添加到项目中
您需要同时拥有 Spring Kafka、Spring Kafka 测试(以运行@EmbeddedBroker
)和 Spring Cloud Contract Stub Runner 在类路径上。记得注释你的测试类
跟@AutoConfigureStubRunner
.
使用 Kafka 集成,为了轮询单个消息,我们需要在 Spring 上下文启动时注册一个消费者。这可能会导致这样一种情况,即当你在使用者端时,Stub Runner 可以为同一组 ID 和主题注册一个额外的使用者。这可能导致只有一个组件实际轮询消息的情况。由于在消费者端,您同时拥有 Spring Cloud Contract Stub Runner 和 Spring Cloud Contract Verifier 类路径,因此我们需要能够关闭此类行为。这是通过stubrunner.kafka.initializer.enabled
标志,这将禁用联系人验证程序使用者注册。如果您的应用程序既是 kafka 消息的使用者又是生产者,您可能需要手动将该属性切换为false
在生成的测试的基类中。
4.10.2. 示例
假设存根结构如下所示:
├── stubs
├── bookDeleted.groovy
├── bookReturned1.groovy
└── bookReturned2.groovy
进一步假设以下测试配置(请注意spring.kafka.bootstrap-servers
通过以下方式指向嵌入式代理的 IP${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
如果您的应用程序使用非整数记录键,则需要将spring.kafka.producer.key-serializer 和spring.kafka.consumer.key-deserializer 属性,因为 Kafka de/serialization 需要非 null
将键记录为整数类型。 |
现在考虑以下合约(我们将它们编号为 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
destination,如下所示:
Message message = MessageBuilder.createMessage(new BookReturned('foo'), new MessageHeaders([sample: "header",]))
kafkaTemplate.setDefaultTopic('delete')
kafkaTemplate.send(message)
5. Spring Cloud 合约存根运行器
使用 Spring Cloud Contract Verifier 时可能遇到的问题之一是 将生成的 WireMock JSON 存根从服务器端传递到客户端(或 各种客户)。在消息传递的客户端生成方面也是如此。
复制 JSON 文件并手动设置客户端以进行消息传递不在 问题。这就是我们引入 Spring Cloud Contract Stub Runner 的原因。它可以 自动下载并运行存根。
5.1. 快照版本
您可以将其他快照存储库添加到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 作为消费者驱动的合约的实现。
Stub Runner 允许您自动下载所提供依赖项的存根(或 从类路径中选择它们),为它们启动 WireMock 服务器,并为它们提供适当的 存根定义。对于消息传递,定义了特殊的存根路由。
5.3.1. 检索存根
您可以从以下获取存根的选项中进行选择:
-
基于以太的解决方案,可从 Artifactory 或 Nexus 下载带有存根的 JAR。
-
类路径扫描解决方案,使用模式搜索类路径以检索存根
-
编写您自己的
org.springframework.cloud.contract.stubrunner.StubDownloaderBuilder
用于完全定制
后一个示例在“自定义存根运行器”部分中进行了描述。
下载存根
您可以使用stubsMode
开关。它从StubRunnerProperties.StubsMode
列举。您可以使用以下选项:
-
StubRunnerProperties.StubsMode.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)
类路径扫描
如果您将stubsMode
属性设置为StubRunnerProperties.StubsMode.CLASSPATH
(或从以下原因开始设置任何内容CLASSPATH
是默认值),则扫描类路径。
请考虑以下示例:
@AutoConfigureStubRunner(ids = {
"com.example:beer-api-producer:+:stubs:8095",
"com.example.foo:bar:1.0.0:superstubs:8096"
})
您可以将依赖项添加到类路径中,如下所示:
<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
}
然后扫描类路径上的指定位置。为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/*/.*
打包 生产者存根。 |
为了实现适当的存根包装,生产商将按如下方式设置合同:
└── src
└── test
└── resources
└── contracts
└── com.example
└── beer-api-producer-restdocs
└── nested
└── contract3.groovy
通过使用专家assembly
插件或 Gradle Jar 任务,您必须创建以下内容
存根罐中的结构:
└── META-INF
└── com.example
└── beer-api-producer-restdocs
└── 2.0.0
├── contracts
│ └── nested
│ └── contract2.groovy
└── mappings
└── mapping.json
通过维护这种结构,类路径将被扫描,您可以从消息传递或 HTTP 存根,无需下载工件。
配置 HTTP 服务器存根
存根运行器有一个概念HttpServerStub
抽象了底层
HTTP 服务器的具体实现(例如,WireMock 是实现之一)。
有时,您需要对存根服务器执行一些额外的调整(这对于给定的实现是具体的)。
为此,Stub Runner 为您提供了
这httpServerStubConfigurer
属性和
JUnit 规则,可通过系统属性访问,您可以在其中提供
您对org.springframework.cloud.contract.stubrunner.HttpServerStubConfigurer
接口。实现可以更改
给定 HTTP 服务器存根的配置文件。
Spring Cloud Contract Stub Runner 附带了一个实现,您可以
可以扩展为 WireMock:org.springframework.cloud.contract.stubrunner.provider.wiremock.WireMockHttpServerStubConfigurer
.
在configure
方法
您可以为给定的存根提供自己的自定义配置。用途
case 可能会在 HTTPS 端口上为给定的工件 ID 启动 WireMock。以下内容
示例显示了如何执行此作:
@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. 运行存根
本节介绍如何运行存根。它包含以下主题:
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
这意味着注册了两个存根。fraudDetectionServer
在端口注册13705
和loanIssuance
在端口12255
.如果我们看一下其中一个文件,我们会看到(对于 WireMock)
给定服务器可用的映射:
[{
"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. 存根运行器 JUnit 规则和存根运行器 JUnit5 扩展
Stub Runner 带有一个 JUnit 规则,可让您下载和运行给定的存根 组和项目 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");
}
一个StubRunnerExtension
也可用于 JUnit 5。StubRunnerRule
和StubRunnerExtension
以非常相似的方式工作。规则或扩展名
执行时,Stub Runner 连接到您的 Maven 存储库,并且对于给定的列表
dependencies,尝试:
-
下载它们
-
将它们缓存在本地
-
将它们解压缩到临时文件夹
-
为每个 Maven 依赖项启动一个 WireMock 服务器,从提供的随机端口 端口范围或提供的端口
-
向 WireMock 服务器提供所有有效的 WireMock 定义的 JSON 文件
-
发送消息(记得传递
MessageVerifier
接口)
Stub Runner 使用 Eclipse Aether 机制下载 Maven 依赖项。 查看他们的文档以获取更多信息。
由于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 规则或 JUnit 5 扩展与消息传递一起使用,您必须提供MessageVerifier 接口到规则构建器(例如,rule.messageVerifier(new MyMessageVerifier()) ).
如果不这样做,则每当您尝试发送消息时,都会抛出异常。 |
5.4.1. Maven 设置
存根下载器遵循不同本地存储库文件夹的 Maven 设置。 当前不考虑存储库和配置文件的身份验证详细信息, 因此,您需要使用上面提到的属性来指定它。
5.4.2. 提供固定端口
您还可以在固定端口上运行存根。您可以通过两种不同的方式进行作。 一种是在属性中传递它,另一种是使用 JUnit 规则。
5.4.3. 流畅的 API
使用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. 带弹簧的短管流道
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
除了使用属性,您还可以使用@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 服务器。以下示例显示了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
可以在代码中引用这些值。
您还可以使用@StubRunnerPort
注释以注入正在运行的存根的端口。
注释的值可以是groupid:artifactid
或者只是artifactid
.
以下示例显示了com.example:thing1
和com.example:thing2
.
@StubRunnerPort("thing1")
int thing1Port;
@StubRunnerPort("com.example:thing2")
int thing2Port;
5.5. 存根运行器 Spring Cloud
Stub Runner 可以与 Spring Cloud 集成。
有关现实生活中的示例,请参阅:
5.5.1. 存根服务发现
最重要的特点Stub Runner Spring Cloud
是它存根的事实:
-
DiscoveryClient
-
Ribbon
ServerList
这意味着,无论您使用 Zookeeper、Consul、Eureka 还是其他任何东西
否则,您在测试中不需要它。我们正在启动您的 WireMock 实例
依赖项,并且无论何时您使用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) 或配置服务器。这就是创建要禁用的其他测试配置的原因 这些功能。
由于某些限制spring-cloud-commons
,
为此,您必须禁用这些属性
在静态块中,例如以下示例(对于 Eureka):
//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. 附加配置
您可以匹配artifactId
存根的存根,使用应用程序的名称stubrunner.idsToServiceIds:
地图。
您可以通过设置stubrunner.cloud.ribbon.enabled
自false
您可以通过设置stubrunner.cloud.enabled
自false
默认情况下,所有服务发现都是存根的。这意味着,无论您是否拥有
现有的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 Boot 应用程序
Spring Cloud Contract Stub Runner Boot 是一个 Spring Boot 应用程序,它将 REST 端点公开给触发消息传递标签并访问 WireMock 服务器。
其中一个用例是在已部署的应用程序上运行一些烟雾(端到端)测试。您可以查看 Spring Cloud Pipelines 项目了解更多信息。
5.6.1. 存根运行器服务器
要使用 Stub Runner Server,请添加以下依赖项:
compile "org.springframework.cloud:spring-cloud-starter-stub-runner"
然后用@EnableStubRunnerServer
,建造一个肥大的罐子,它就可以工作了。
有关属性,请参阅短截线流道弹簧部分。
5.6.2. 存根运行器服务器脂肪罐
您可以从 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
从1.4.0.RELEASE
Spring Cloud CLI 项目版本,您可以通过运行spring cloud stubrunner
.
为了传递配置,您可以创建一个stubrunner.yml
当前工作目录中的文件,在名为config
,或在~/.spring-cloud
.该文件可能类似于以下内容
运行本地安装的存根的示例:
stubrunner:
stubsMode: LOCAL
ids:
- com.example:beer-api-producer:+:9876
然后你可以调用spring cloud stubrunner
从终端窗口开始
存根运行器服务器。在端口可用8750
.
5.6.4. 端点
Stub Runner Boot 提供两个端点:
HTTP
对于 HTTP,存根运行器启动使以下端点可用:
-
获取
/stubs
:返回ivy:integer
表示法 -
获取
/stubs/{ivy}
:返回给定的端口ivy
表示法(调用端点时ivy
也可以是artifactId
仅)
消息
对于消息传递,存根运行器启动使以下端点可用:
-
获取
/triggers
:返回ivy : [ label1, label2 …]
表示法 -
发布
/triggers/{label}
:运行触发器label
-
发布
/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 Boot 的一种方法是将其用作“冒烟测试”的存根源。那是什么意思? 假设您不想按顺序将 50 个微服务部署到测试环境 以查看您的应用程序是否有效。在构建过程中,您已经执行了一套测试, 但您还希望确保应用程序的打包有效。您可以 将应用程序部署到环境,启动它,然后对其运行几个测试,看看是否 它有效。我们可以将这些测试称为“烟雾测试”,因为它们的目的只是检查少数几个 测试场景。
这种方法的问题在于,如果您使用微服务,您很可能还会 使用服务发现工具。Stub Runner Boot 允许您通过启动 必需的存根,并在服务发现工具中注册它们。考虑以下示例 使用 Eureka 进行这样的设置(假设 Eureka 已经在运行):
@SpringBootApplication
@EnableStubRunnerServer
@EnableEurekaClient
@AutoConfigureStubRunner
public class StubRunnerBootEurekaExample {
public static void main(String[] args) {
SpringApplication.run(StubRunnerBootEurekaExample.class, args);
}
}
我们想要启动一个存根运行器启动服务器 (@EnableStubRunnerServer
),启用 Eureka 客户端 (@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
这样,部署的应用程序可以通过服务向已启动的 WireMock 服务器发送请求
发现。最有可能的是,默认情况下可以设置点 1 到 3application.yml
,因为它们不是
可能会改变。这样,您就可以在启动时仅提供要下载的存根列表
短滑道靴子。
5.7. 消费者驱动的合同:每个消费者的存根
在某些情况下,同一终结点的两个使用者希望有两个不同的响应。
这种方法还可以让您立即知道哪个使用者使用了 API 的哪一部分。您可以删除 API 生成的部分响应,并查看自动生成的测试 失败。 如果没有失败,您可以安全地删除响应的该部分,因为没有人使用它。 |
考虑以下为生产者定义的名为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"
}
}
您不能为同一个请求生成两个不同的响应。这就是为什么您可以正确打包contracts,然后从stubsPerConsumer
特征。
在生产者端,消费者可以拥有一个文件夹,其中包含仅与他们相关的合同。通过将stubrunner.stubs-per-consumer
flag 到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 {
...
}
然后,只有在包含以下内容的路径下注册的存根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 {
...
}
然后,只有在包含foo-consumer
在其名称中(即来自src/test/resources/contracts/foo-consumer/some/contracts/…
文件夹)被允许引用。
有关更多信息,请参阅第 224 期有关此更改背后原因的信息。
5.8. 从某个位置获取存根或合约定义
与其从Artifactory / Nexus 或 Git 中选择存根或合约定义,只需指向驱动器或类路径上的位置。这在多模块项目中特别有用,其中一个模块想要重用另一个模块中的存根或合约,而无需需要在本地 Maven 中实际安装它们存储库将这些更改提交到 Git。
为了实现这一点,使用stubs://
协议,当存储库根参数设置为在存根运行器或 Spring Cloud Contract 插件中。
在此示例中,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);
合约和存根可以存储在某个位置,每个生产者都有自己的专用文件夹来存储合约和存根映射。在该文件夹下,每个使用者都可以有自己的设置。要使 Stub Runner 从提供的 ID 中找到专用文件夹,可以传递一个属性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 | 使用者的组 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. 在运行时生成存根
作为消费者,您可能不想等待生产者完成其实现,然后发布他们的存根。此问题的解决方案可以在运行时生成存根。
作为生产者,当定义契约时,您需要使生成的测试通过才能发布存根。在某些情况下,您希望取消阻止消费者,以便他们可以在测试实际通过之前获取存根。在这种情况下,您应该将此类合约设置为正在进行中。您可以在 Contracts in Progress 部分下阅读更多相关信息。这样,您的测试将不会生成,但存根会生成。
作为消费者,您可以切换开关以在运行时生成存根。存根运行器将忽略所有现有的存根映射,并为所有合约定义生成新的存根映射。另一种选择是将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. 无存根失败
默认情况下,如果未找到存根,则存根运行器将失败。要更改该行为,只需设置为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 配置来设置重复属性 性能。 下表显示了它们的名称及其默认值:
属性名称 | 默认值 | 描述 |
---|---|---|
存根运行器.min端口 |
10000 |
已启动的带有存根的 WireMock 的端口的最小值。 |
存根运行器.maxPort |
15000 |
已启动的带有存根的 WireMock 的端口的最大值。 |
stubrunner.repositoryRoot |
Maven 存储库 URL。如果为空,则调用本地 Maven 存储库。 |
|
存根运行器.分类器 |
存根 |
存根工件的默认分类器。 |
存根运行器.存根模式 |
类路径 |
您想要获取和注册存根的方式 |
存根运行器.ids |
要下载的常春藤符号存根数组。 |
|
存根运行器.用户名 |
可选用户名,用于访问存储 JAR 的工具 存根。 |
|
存根跑器.密码 |
用于访问存储 JAR 的工具的可选密码 存根。 |
|
stubrunner.stubsPerConsumer |
|
设置为 |
stubrunner.consumerName |
如果要为每个使用者使用存根,并且想要 覆盖使用者名称,更改此值。 |
5.11.2. 存根运行器存根 ID
您可以在stubrunner.ids
系统属性。 他们 使用以下模式:
groupId:artifactId:version:classifier:port
请注意version
,classifier
和port
是可选的。
-
如果您不提供
port
,随机选择一个。 -
如果您不提供
classifier
,则使用默认值。(请注意,您可以 通过以下方式传递一个空分类器:groupId:artifactId:version:
). -
如果您不提供
version
,然后通过,最新的是 下载。+
port
表示 WireMock 服务器的端口。
从 1.0.4 版开始,您可以提供一系列版本 希望存根运行器考虑。您可以阅读有关以太版本控制的更多信息 范围在这里。 |
6. Spring Cloud 合约 WireMock
如果你有一个使用 Tomcat 作为嵌入式服务器的 Spring Boot 应用程序(即
默认为spring-boot-starter-web
),您可以添加spring-cloud-starter-contract-stub-runner
到您的类路径并将@AutoConfigureWireMock
在测试中使用 Wiremock。Wiremock 作为存根服务器运行,而您
可以使用 Java API 或使用静态 JSON 声明作为
你的测试。以下代码显示了一个示例:
@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!");
}
}
要在其他端口上启动存根服务器,请使用(例如)@AutoConfigureWireMock(port=9999)
.对于随机端口,请使用0
.存根
服务器端口可以在测试应用程序上下文中使用“wiremock.server.port”绑定
财产。用@AutoConfigureWireMock
添加类型WiremockConfiguration
自
测试应用程序上下文,它在方法和类之间缓存
具有相同的上下文。Spring 集成测试也是如此。此外,您还可以
注入类型为WireMockServer
进入您的测试。
注册的 WireMock 服务器在每个测试类后重置,但是,如果您需要在每个测试方法之后重置它,只需将wiremock.reset-mappings-after-each-test
属性设置为true
.
6.1. 自动注册存根
如果您使用@AutoConfigureWireMock
,它从文件中注册 WireMock JSON 存根
系统或类路径(默认情况下,从file:src/test/resources/mappings
).您可以
使用stubs
属性,可以是
Ant 样式的资源模式或目录。对于目录,*/.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 总是从src/test/resources/mappings 如
以及stubs 属性。要更改此行为,您可以
此外,请指定文件根目录,如本文档的下一节所述。 |
此外,在stubs location 不被视为 Wiremock 的“默认映射”的一部分,调用
自com.github.tomakehurst.wiremock.client.WireMock.resetToDefaultMappings 在测试期间不会导致映射
在stubs 包括位置。但是,org.springframework.cloud.contract.wiremock.WireMockTestExecutionListener 在每个测试类之后重置映射(包括从存根位置添加映射),并且(可选)
在每个测试方法(由wiremock.reset-mappings-after-each-test 属性)。 |
如果您使用 Spring Cloud Contract 的默认存根 jar,则您的
存根存储在/META-INF/group-id/artifact-id/versions/mappings/
文件夹。
如果要从该位置注册所有嵌入 JAR 的所有存根,您可以使用
以下语法:
@AutoConfigureWireMock(port = 0, stubs = "classpath*:/META-INF/**/mappings/**/*.json")
6.2. 使用文件指定存根主体
WireMock 可以从类路径或文件系统上的文件中读取响应正文。 在 文件系统的情况下,您可以在 JSON DSL 中看到响应具有bodyFileName
而不是(文字)body
. 这些文件是相对于根目录解析的(默认情况下,src/test/resources/__files
). 要自定义此位置,您可以将files
属性中的@AutoConfigureWireMock
注释到父级目录的位置(换句话说,__files
是一个子目录)。您可以使用 Spring 资源表示法来引用file:…
或classpath:…
地点。通用 URL 不是
支持。可以给出一个值列表——在这种情况下,WireMock 解析第一个文件
当它需要找到响应机构时,它就存在。
当您配置files root,它还会影响
自动加载存根,因为它们来自根位置
在名为mappings .的值files 没有
对从stubs 属性。 |
6.3. 替代方案:使用 JUnit 规则
对于更传统的 WireMock 体验,您可以使用 JUnit@Rules
启动和停止
服务器。为此,请使用WireMockSpring
convenience 类来获取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 允许您使用https
URL 协议。如果您的
应用程序想要在集成测试中联系该存根服务器,它会发现
SSL 证书无效(自安装证书的常见问题)。
最好的选择通常是重新配置客户端以使用http
.如果那不是
选项,您可以要求 Spring 配置一个忽略 SSL 验证错误的 HTTP 客户端
(当然,仅针对测试)。
为了以最小的大惊小怪做到这一点,您需要使用 Spring BootRestTemplateBuilder
如以下示例所示:
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
你需要RestTemplateBuilder
因为构建器是通过回调传递给
初始化它,以便此时可以在客户端中设置 SSL 验证。这
如果您在测试中使用@AutoConfigureWireMock
注释或存根运行器。如果您使用 JUnit@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));
...
}
如果您使用spring-boot-starter-test
,则 Apache HTTP 客户端的
类路径,并且它是由RestTemplateBuilder
并配置为忽略 SSL
错误。如果使用默认的java.net
client,则不需要注释(但
没有坏处)。目前不支持其他客户端,但可能会添加
在未来的版本中。
禁用自定义RestTemplateBuilder
,将wiremock.rest-template-ssl-enabled
属性设置为false
.
6.5. WireMock 和 Spring MVC 模拟
Spring Cloud Contract 提供了一个便利类,可以将 JSON WireMock 存根加载到一个 SpringMockRestServiceServer
. 以下代码显示了一个示例:
@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();
}
}
这baseUrl
value 被附加到所有模拟调用的前面,并且stubs()
方法采用存根path 资源模式作为参数。在前面的示例中,在/stubs/resource.json
加载到模拟服务器中。如果RestTemplate
被要求 访问example.org/
,它会获取在该 URL 上声明的响应。 更多 可以指定一个以上的存根模式,并且每个存根模式都可以是一个目录(对于递归所有.json
)、固定文件名(如前面的示例所示)或 Ant 样式
模式。JSON 格式是普通的 WireMock 格式,您可以在 WireMock 网站上阅读有关该格式的信息。
目前,Spring Cloud Contract Verifier 支持 Tomcat、Jetty 和 Undertow 作为 Spring Boot 嵌入式服务器,Wiremock 本身对特定的 Jetty 版本(当前为 9.2)。要使用原生 Jetty,您需要添加原生 Wiremock 依赖项并排除 Spring Boot 容器(如果有)。
8. 接下来要读什么
如果您对 Spring Cloud Contract 的核心功能感到满意,您可以继续阅读并阅读关于 Spring Cloud Contract 的高级功能。