Spring Cloud Contract 功能

1. 合同 DSL

Spring Cloud Contract 支持以下语言编写的 DSL:spring-doc.cadn.net.cn

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

以下示例显示了一个协定定义:spring-doc.cadn.net.cn

槽的
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()
    }
}
YML
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)
Java
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());
                });
            });
        }));
    }

}
kotlin
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 命令将合同编译为存根映射:spring-doc.cadn.net.cn

mvn org.springframework.cloud:spring-cloud-contract-maven-plugin:convert

1.1. 在 Groovy 中约定 DSL

如果您不熟悉 Groovy,请不要担心 - 您可以在 Groovy DSL 文件。spring-doc.cadn.net.cn

如果您决定用 Groovy 编写合约,如果您还没有使用 Groovy,请不要惊慌 以前。实际上并不需要了解该语言,因为 Contract DSL 只使用 它的一小部分(仅 Literals、Method Calls 和 Closure)。此外,DSL 是静态的 typed,使其无需任何 DSL 本身知识即可程序员可读。spring-doc.cadn.net.cn

请记住,在 Groovy 合约文件中,您必须提供完整的 限定名称设置为Contractclass 和makestatic imports,例如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>>对于多个合同。spring-doc.cadn.net.cn

您还可以在src/test/java(例如src/test/java/contracts),这样就不必修改项目的 Classpath 了。在这种情况下,您必须为 Spring Cloud Contract 插件提供 Contract 定义的新位置。spring-doc.cadn.net.cn

Maven 系列
<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>
Gradle
contracts {
    contractsDslDir = new File(project.rootDir, "src/test/java/contracts")
}

1.3. 在 Kotlin 中约定 DSL

要开始使用 Kotlin 编写协定,您需要从(新创建的)Kotlin 脚本文件 (.kts) 开始。 就像使用 Java DSL 一样,你可以把你的 Contract 放在你选择的任何目录中。 Maven 和 Gradle 插件将查看src/test/resources/contracts目录中。spring-doc.cadn.net.cn

您需要显式地将spring-cloud-contract-spec-kotlin依赖项添加到您的项目插件设置中。spring-doc.cadn.net.cn

Maven 系列
<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>
Gradle
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类。 通常,您可以像这样使用它的 contract 函数:org.springframework.cloud.contract.spec.ContractDsl.contract { …​ }. 您还可以提供对contract函数 (import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract),然后调用contract { …​ }.

1.4. YML 中的合约 DSL

要查看 YAML 协定的架构,您可以查看 YML 架构页面。spring-doc.cadn.net.cn

1.5. 限制

对验证 JSON 数组大小的支持是实验性的。如果需要帮助, 要启用它,请将以下 System 属性的值设置为true:spring.cloud.contract.verifier.assert.size.默认情况下,此功能设置为false. 您还可以设置assertJsonSize属性。
由于 JSON 结构可以具有任何形式,因此可能无法对其进行解析 正确地使用 Groovy DSL 和value(consumer(…​), producer(…​))表示法GString.那 是您应该使用 Groovy Map 表示法的原因。

1.6. 常见的顶级元素

以下部分介绍了最常见的顶级元素:spring-doc.cadn.net.cn

1.6.1. 描述

您可以添加description添加到您的合同中。描述是任意文本。这 下面的代码显示了一个示例:spring-doc.cadn.net.cn

槽的
            org.springframework.cloud.contract.spec.Contract.make {
                description('''
given:
    An input
when:
    Sth happens
then:
    Output
''')
            }
YML
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)
Java
Contract.make(c -> {
    c.description("Some description");
}));
kotlin
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.spring-doc.cadn.net.cn

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

以下示例演示如何向协定添加名称:spring-doc.cadn.net.cn

槽的
org.springframework.cloud.contract.spec.Contract.make {
    name("some_special_name")
}
YML
name: some name
Java
Contract.make(c -> {
    c.name("some name");
}));
kotlin
contract {
    name = "some_special_name"
}

1.6.3. 忽略 Contract

如果要忽略合约,可以在 plugin 配置或设置ignoredproperty 的 intent 属性。以下内容 示例展示了如何做到这一点:spring-doc.cadn.net.cn

槽的
org.springframework.cloud.contract.spec.Contract.make {
    ignored()
}
YML
ignored: true
Java
Contract.make(c -> {
    c.ignored();
}));
kotlin
contract {
    ignored = true
}

1.6.4. 正在进行的合同

正在进行的合同不会在生产者端生成测试,但允许生成存根。spring-doc.cadn.net.cn

请谨慎使用此功能,因为它可能会导致误报。您可以生成存根供使用者使用,而无需实际实施!

如果要设置正在进行的合同,请执行以下作 示例展示了如何做到这一点:spring-doc.cadn.net.cn

槽的
org.springframework.cloud.contract.spec.Contract.make {
    inProgress()
}
YML
inProgress: true
Java
Contract.make(c -> {
    c.inProgress();
}));
kotlin
contract {
    inProgress = true
}

您可以设置failOnInProgressSpring Cloud Contract 插件属性,以确保当源中至少有一个正在进行的 Contract 时,您的构建将中断。spring-doc.cadn.net.cn

1.6.5. 从文件传递值

从 version 开始1.2.0中,您可以从文件中传递值。假设您拥有 项目中的以下资源:spring-doc.cadn.net.cn

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

进一步假设您的 Contract 如下:spring-doc.cadn.net.cn

槽的
/*
 * 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())
        }
    }
}
YML
request:
  method: GET
  url: /foo
  bodyFromFile: request.json
response:
  status: 200
  bodyFromFile: response.json
Java
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"));
            });
        }));
    }

}
kotlin
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 文件如下所示:spring-doc.cadn.net.cn

request.json
{
  "status": "REQUEST"
}
response.json
{
  "status": "RESPONSE"
}

当 test 或 stub 生成时,request.jsonresponse.json文件传递到正文 请求或响应。文件名需要是位置 相对于合约所在的文件夹。spring-doc.cadn.net.cn

如果需要以二进制形式传递文件的内容, 您可以使用fileAsBytes方法或bodyFromFileAsBytes字段。spring-doc.cadn.net.cn

以下示例演示如何传递二进制文件的内容:spring-doc.cadn.net.cn

槽的
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())
        }
    }
}
YML
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
Java
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());
                });
            });
        }));
    }

}
kotlin
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 存根,对于与提供条件匹配的任何请求,该存根提供 适当的回应。spring-doc.cadn.net.cn

2.1. HTTP 顶级元素

您可以在合约定义的顶级闭包中调用以下方法:spring-doc.cadn.net.cn

以下示例演示如何定义 HTTP 请求协定:spring-doc.cadn.net.cn

槽的
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
}
YML
priority: 8
request:
...
response:
...
Java
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);
});
kotlin
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。这 在合同的请求定义中,相同的信息是必需的。spring-doc.cadn.net.cn

以下示例显示了请求的协定:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
method: PUT
url: /foo
Java
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);
    });
});
kotlin
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是 推荐的方法,因为这样做会使测试独立于主机。spring-doc.cadn.net.cn

以下示例使用url:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
request:
  method: PUT
  urlPath: /foo
Java
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);
    });
});
kotlin
contract {
    request {
        method = GET

        // Specifying `url` and `urlPath` in one contract is illegal.
        url("http://localhost:8888/users")
    }
    response {
        // ...
        status = OK
    }
}

request可能包含查询参数,如以下示例(使用urlPath) 显示:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
request:
...
queryParameters:
  a: b
  b: c
Java
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);
    });
});
kotlin
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可以包含其他请求标头,如下例所示:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
request:
...
headers:
  foo: bar
  fooReq: baz
Java
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);
    });
});
kotlin
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,如下例所示:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
request:
...
cookies:
  foo: bar
  fooReq: baz
Java
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);
    });
});
kotlin
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可能包含请求正文,如下例所示:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
request:
...
body:
  foo: bar
Java
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);
    });
});
kotlin
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 元素。要包含多部分元素,请使用multipartmethod/section 中,如下例所示:spring-doc.cadn.net.cn

槽的
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
    }
}
YML
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
Java
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());
            });
        }));
    }

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

在前面的示例中,我们通过以下两种方式之一定义参数:spring-doc.cadn.net.cn

编码 DSL
  • 直接使用映射表示法,其中值可以是动态属性(例如formParameter: $(consumer(…​), producer(…​))).spring-doc.cadn.net.cn

  • 通过使用named(…​)方法,用于设置命名参数。命名参数 可以设置namecontent.您可以使用带有两个参数的方法 如named("fileName", "fileContent"),或者使用映射表示法(如named(name: "fileName", content: "fileContent").spring-doc.cadn.net.cn

YAML
  • multipart 参数在multipart.params部分。spring-doc.cadn.net.cn

  • 命名参数(fileNamefileContent对于给定的参数名称) 可以在multipart.named部分。该部分包含 这paramName(参数名称)、fileName(文件名)、fileContent(文件的内容)字段。spring-doc.cadn.net.cn

  • 动态位可以通过matchers.multipart部分。spring-doc.cadn.net.cn

    • 对于参数,请使用params部分,该部分可以接受regexpredefined正则表达式。spring-doc.cadn.net.cn

    • 对于命名参数,请使用named部分 定义参数名称paramName.然后,您可以将 参数化fileNamefileContentregex或在predefined正则表达式。spring-doc.cadn.net.cn

在前面示例中的 Contract 中,生成的 test 和 stub 如下所示:spring-doc.cadn.net.cn

测试
// 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 状态代码,并且可能包含其他信息。这 下面的代码显示了一个示例:spring-doc.cadn.net.cn

槽的
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()
    }
}
YML
response:
...
status: 200
Java
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());
    });
});
kotlin
contract {
    request {
        // ...
        method = GET
        url =url("/foo")
    }
    response {
        // Status code sent by the server
        // in response to request specified above.
        status = OK
    }
}

除了 status 之外,响应还可能包含 headers、cookie 和 body,它们是 指定方式与请求中相同(请参阅 HTTP 请求)。spring-doc.cadn.net.cn

在 Groovy DSL 中,您可以引用org.springframework.cloud.contract.spec.internal.HttpStatus方法来提供有意义的状态而不是数字。例如,您可以调用OK()对于状态200BAD_REQUEST()400.

2.4. 动态属性

合同可以包含一些动态属性:时间戳、ID 等。你不需要 想要强制 Consumer 对他们的 clockstub 进行存根,以始终返回相同的 time 值 以便它与存根匹配。spring-doc.cadn.net.cn

对于 Groovy DSL,您可以在 Contract 中提供动态部分 有两种方式:直接在正文中传递它们,或者将它们设置在名为bodyMatchers.spring-doc.cadn.net.cn

在 2.0.0 之前,这些是通过使用testMatchersstubMatchers. 有关更多信息,请参阅迁移指南

对于 YAML,您只能使用matchers部分。spring-doc.cadn.net.cn

条目中的matchers必须引用有效负载的现有元素。有关更多信息,请查看此问题

2.4.1. Body 内部的动态属性

此部分仅对编码 DSL(Groovy、Java 等)有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。

您可以使用value方法,或者如果您使用 Groovy 映射表示法,其中 .以下示例演示如何设置 dynamic properties 替换为 value 方法:$()spring-doc.cadn.net.cn

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

这两种方法同样有效。这stubclient方法是consumer方法。后续部分将仔细研究您可以对这些值执行哪些作。spring-doc.cadn.net.cn

2.4.2. 正则表达式

此部分仅对 Groovy DSL 有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。

您可以使用正则表达式在合约 DSL 中写入您的请求。这样做是 当您想要指示应提供给定响应时,特别有用 对于遵循给定模式的请求。此外,在以下情况下,您可以使用正则表达式 需要对测试和服务器端测试使用模式而不是精确值。spring-doc.cadn.net.cn

确保 regex 与序列的整个区域匹配,因为在内部,对Pattern.matches()被调用。例如abc不匹配aabc.abc确实。 还有一些其他已知限制spring-doc.cadn.net.cn

以下示例演示如何使用正则表达式编写请求:spring-doc.cadn.net.cn

槽的
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'
        }
    }
}
Java
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");
        });
    });
});
kotlin
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 的示例:spring-doc.cadn.net.cn

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

在前面的示例中,通信的另一端具有相应的数据 为 request 和 response 生成。spring-doc.cadn.net.cn

Spring Cloud Contract 附带了一系列预定义的正则表达式,您可以 在你的 Contract 中使用,如下例所示:spring-doc.cadn.net.cn

public static RegexProperty onlyAlphaUnicode() {
    return new RegexProperty(ONLY_ALPHA_UNICODE).asString();
}

public static RegexProperty alphaNumeric() {
    return new RegexProperty(ALPHA_NUMERIC).asString();
}

public static RegexProperty number() {
    return new RegexProperty(NUMBER).asDouble();
}

public static RegexProperty positiveInt() {
    return new RegexProperty(POSITIVE_INT).asInteger();
}

public static RegexProperty anyBoolean() {
    return new RegexProperty(TRUE_OR_FALSE).asBooleanType();
}

public static RegexProperty anInteger() {
    return new RegexProperty(INTEGER).asInteger();
}

public static RegexProperty aDouble() {
    return new RegexProperty(DOUBLE).asDouble();
}

public static RegexProperty ipAddress() {
    return new RegexProperty(IP_ADDRESS).asString();
}

public static RegexProperty hostname() {
    return new RegexProperty(HOSTNAME_PATTERN).asString();
}

public static RegexProperty email() {
    return new RegexProperty(EMAIL).asString();
}

public static RegexProperty url() {
    return new RegexProperty(URL).asString();
}

public static RegexProperty httpsUrl() {
    return new RegexProperty(HTTPS_URL).asString();
}

public static RegexProperty uuid() {
    return new RegexProperty(UUID).asString();
}

public static RegexProperty isoDate() {
    return new RegexProperty(ANY_DATE).asString();
}

public static RegexProperty isoDateTime() {
    return new RegexProperty(ANY_DATE_TIME).asString();
}

public static RegexProperty isoTime() {
    return new RegexProperty(ANY_TIME).asString();
}

public static RegexProperty iso8601WithOffset() {
    return new RegexProperty(ISO8601_WITH_OFFSET).asString();
}

public static RegexProperty nonEmpty() {
    return new RegexProperty(NON_EMPTY).asString();
}

public static RegexProperty nonBlank() {
    return new RegexProperty(NON_BLANK).asString();
}

在你的 Contract 中,你可以按如下方式使用它(Groovy DSL 的示例):spring-doc.cadn.net.cn

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前缀,如下所示:spring-doc.cadn.net.cn

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);

下面的示例演示如何引用这些方法:spring-doc.cadn.net.cn

槽的
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'))
        ])
    }
}
kotlin
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")
        }
    }
}
局限性
由于Xeger库生成一个字符串 一个正则表达式,如果您依赖 Automatic,请不要在正则表达式中使用 和 符号 代。参见 问题 899$^
请勿使用LocalDateinstance 的值(例如$$(consumer(LocalDate.now()))). 它会导致java.lang.StackOverflowError.用$(consumer(LocalDate.now().toString()))相反。 参见 问题 900

2.4.3. 传递可选参数

此部分仅对 Groovy DSL 有效。查看 Matchers Sections 部分中的 Dynamic Properties 以获取类似功能的 YAML 示例。

您可以在合同中提供可选参数。但是,您可以提供 可选参数仅适用于以下各项:spring-doc.cadn.net.cn

以下示例说明如何提供可选参数:spring-doc.cadn.net.cn

槽的
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")))
        )
    }
}
Java
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")))));
    });
});
kotlin
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()方法中,您可以创建一个常规的 expression 的表达式,该表达式必须出现 0 次或多次。spring-doc.cadn.net.cn

如果您使用 Spock,则将根据前面的示例生成以下测试:spring-doc.cadn.net.cn

槽的
                    """\
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}

}
"""

还将生成以下存根:spring-doc.cadn.net.cn

                    '''
{
  "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在配置中。这 以下代码显示了测试用例的 Contract 部分的示例:spring-doc.cadn.net.cn

槽的
method GET()
Java
r.method(r.GET());
kotlin
method = GET

下面的代码显示了测试用例的基类部分:spring-doc.cadn.net.cn

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
    }

}
您不能同时使用Stringexecute执行串联。为 示例,调用header('Authorization', 'Bearer ' + execute('authToken()'))导致 不正确的结果。相反,调用header('Authorization', execute('authToken()'))和 确保authToken()method 返回您需要的所有内容。

从 JSON 中读取的对象类型可以是以下类型之一,具体取决于 JSON 路径:spring-doc.cadn.net.cn

在合约的请求部分,您可以指定body应取自 方法。spring-doc.cadn.net.cn

您必须同时提供使用者端和生产者端。这execute部分 应用于整个身体,而不是身体的某些部分。

以下示例显示如何从 JSON 中读取对象:spring-doc.cadn.net.cn

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

前面的示例导致调用hashCode()方法。 它应类似于以下代码:spring-doc.cadn.net.cn

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

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

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

2.4.5. 从响应中引用 Request

最好的情况是提供固定值,但有时您需要引用 request 的响应。spring-doc.cadn.net.cn

如果你在 Groovy DSL 中编写契约,你可以使用fromRequest()方法,它让 您从 HTTP 请求中引用了一组元素。您可以使用以下内容 选项:spring-doc.cadn.net.cn

如果使用 YAML Contract 定义或 Java Contract 定义,则必须将 Handlebars 表示法与自定义 Spring Cloud Contract 一起使用 功能来实现此目的。在这种情况下,您可以使用以下选项:{{{ }}}spring-doc.cadn.net.cn

  • {{{ request.url }}}:返回请求 URL 和查询参数。spring-doc.cadn.net.cn

  • {{{ request.query.key.[index] }}}:返回具有给定名称的第 n 个查询参数。 例如,对于thing,则第一个条目为{{{ request.query.thing.[0] }}}spring-doc.cadn.net.cn

  • {{{ request.path }}}:返回完整路径。spring-doc.cadn.net.cn

  • {{{ request.path.[index] }}}:返回第 n 个 path 元素。例如 第一个条目是 {{{ request.path.[0] }}}`spring-doc.cadn.net.cn

  • {{{ request.headers.key }}}:返回具有给定名称的第一个标头。spring-doc.cadn.net.cn

  • {{{ request.headers.key.[index] }}}:返回具有给定名称的第 n 个标头。spring-doc.cadn.net.cn

  • {{{ request.body }}}:返回完整的请求正文。spring-doc.cadn.net.cn

  • {{{ jsonpath this 'your.json.path' }}}:从请求中返回 匹配 JSON 路径。例如,对于 JSON 路径$.here{{{ jsonpath this '$.here' }}}spring-doc.cadn.net.cn

考虑以下合约:spring-doc.cadn.net.cn

槽的
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())
    }
}
YML
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"
Java
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());
                });
            });
        });
    }

}
kotlin
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 测试生成会导致类似于以下示例的测试:spring-doc.cadn.net.cn

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

如您所见,请求中的元素已在响应中正确引用。spring-doc.cadn.net.cn

生成的 WireMock 存根应类似于以下示例:spring-doc.cadn.net.cn

{
  "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部分合约结果 在发送以下响应正文时:spring-doc.cadn.net.cn

{
  "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 模板转换为 适当的值。此外,它还注册了两个帮助程序函数:{{{ }}}

2.4.6. Matchers 部分中的动态属性

如果您使用 Pact,以下讨论可能看起来很熟悉。 相当多的用户习惯于在 body 和设置 合同的动态部分。spring-doc.cadn.net.cn

您可以使用bodyMatchers部分,原因有二:spring-doc.cadn.net.cn

  • 定义应以存根结尾的动态值。 您可以在requestinputMessage合同的一部分。spring-doc.cadn.net.cn

  • 验证您的测试结果。 此部分位于responseoutputMessage的 合同。spring-doc.cadn.net.cn

目前, Spring Cloud Contract Verifier 仅支持基于 JSON 路径的匹配器,其中 以下匹配可能性:spring-doc.cadn.net.cn

编码 DSL
  • 对于 stub (在使用者端的测试中):spring-doc.cadn.net.cn

    • byEquality():从提供的 JSON 路径中的使用者请求中获取的值必须为 等于合同中提供的值。spring-doc.cadn.net.cn

    • byRegex(…​):从提供的 JSON 路径中的使用者请求中获取的值必须 匹配正则表达式。您还可以传递预期匹配值的类型(例如asString(),asLong()等)。spring-doc.cadn.net.cn

    • byDate():从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO 日期值的正则表达式。spring-doc.cadn.net.cn

    • byTimestamp():从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO DateTime 值的正则表达式。spring-doc.cadn.net.cn

    • byTime():从提供的 JSON 路径中的使用者请求中获取的值必须 匹配 ISO Time 值的正则表达式。spring-doc.cadn.net.cn

  • 对于验证(在 Producer 端生成的测试中):spring-doc.cadn.net.cn

    • byEquality():从提供的 JSON 路径中的生产者响应中获取的值必须为 等于 Contract 中提供的值。spring-doc.cadn.net.cn

    • byRegex(…​):从提供的 JSON 路径中的生产者响应中获取的值必须 匹配正则表达式。spring-doc.cadn.net.cn

    • byDate():从提供的 JSON 路径中的生产者响应中获取的值必须匹配 ISO Date 值的正则表达式。spring-doc.cadn.net.cn

    • byTimestamp():从提供的 JSON 路径中的生产者响应中获取的值必须 匹配 ISO DateTime 值的正则表达式。spring-doc.cadn.net.cn

    • byTime():从提供的 JSON 路径中的生产者响应中获取的值必须匹配 ISO Time 值的正则表达式。spring-doc.cadn.net.cn

    • byType():从提供的 JSON 路径中的生产者响应中获取的值需要为 与协定中响应正文中定义的类型相同。byType可以接受一个闭包,你可以在其中设置minOccurrencemaxOccurrence.对于 请求端,你应该使用 closure 来断言集合的大小。 这样,您就可以断言扁平化集合的大小。要检查 unflattened 集合中,请使用byCommand(…​) testMatcher.spring-doc.cadn.net.cn

    • byCommand(…​):从提供的 JSON 路径中的生产者响应中获取的值为 作为输入传递给您提供的自定义方法。例如byCommand('thing($it)')导致调用thing方法,其中与 JSON 路径被传递。从 JSON 中读取的对象类型可以是 ,具体取决于 JSON 路径:spring-doc.cadn.net.cn

    • byNull():从提供的 JSON 路径中的响应中获取的值必须为 null。spring-doc.cadn.net.cn

YAML
有关 类型含义。

对于 YAML,匹配器的结构类似于以下示例:spring-doc.cadn.net.cn

- 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],您可以使用类似于以下示例的内容:spring-doc.cadn.net.cn

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

以下列表显示了允许的type值:spring-doc.cadn.net.cn

您还可以在regexType田。以下列表显示了允许的正则表达式类型:spring-doc.cadn.net.cn

请考虑以下示例:spring-doc.cadn.net.cn

槽的
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}'))))
        }
    }
}
YML
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 的 intent 是对于valueWithoutAMatcher,则会进行验证 与不使用 matchers 的方式相同。在这种情况下,测试会执行 相等性检查。spring-doc.cadn.net.cn

对于 Response 端的bodyMatchers部分中,我们在 类似的方式。唯一的区别是byType匹配程序也存在。这 验证程序引擎检查四个字段,以验证来自测试的响应 的值 JSON 路径与给定字段匹配,与该值的类型相同 定义,并通过以下检查(基于正在调用的方法):spring-doc.cadn.net.cn

  • $.valueWithTypeMatch,引擎会检查类型是否相同。spring-doc.cadn.net.cn

  • $.valueWithMin时,引擎会检查类型并断言大小是否更大 小于或等于最小出现次数。spring-doc.cadn.net.cn

  • $.valueWithMax时,引擎会检查类型并断言大小是否为 小于或等于最大出现次数。spring-doc.cadn.net.cn

  • $.valueWithMinMax时,引擎会检查类型并断言大小是否为 介于最小出现次数和最大出现次数之间。spring-doc.cadn.net.cn

生成的测试类似于以下示例(请注意,and部分 将自动生成的断言和断言与 matchers 分开):spring-doc.cadn.net.cn

// 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.此方法必须在测试基类中定义,或者是 静态导入到您的测试中。请注意,byCommandcall 已转换为assertThatValueIsANumber(parsedJson.read("$.duck"));.这意味着发动机需要 方法名称,并将正确的 JSON 路径作为参数传递给它。

生成的 WireMock 存根位于以下示例中:spring-doc.cadn.net.cn

                    '''
{
  "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 路径的地址将从断言中删除。在 验证集合时,您必须为 收集。

请考虑以下示例:spring-doc.cadn.net.cn

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

前面的代码导致创建以下测试(代码块仅显示 assertion 部分):spring-doc.cadn.net.cn

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(…​)方法。spring-doc.cadn.net.cn

2.5. 异步支持

如果您在服务器端使用异步通信(您的控制器是 返回Callable,DeferredResult等),那么,在您的 Contract 中,您必须 提供async()方法中的response部分。下面的代码显示了一个示例:spring-doc.cadn.net.cn

槽的
org.springframework.cloud.contract.spec.Contract.make {
    request {
        method GET()
        url '/get'
    }
    response {
        status OK()
        body 'Passed'
        async()
    }
}
YML
response:
    async: true
Java
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();
                // ...
            });
        }));
    }

}
kotlin
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract

contract {
    request {
        // ...
    }
    response {
        async = true
        // ...
    }
}

您还可以使用fixedDelayMilliseconds方法或属性来向存根添加延迟。 以下示例显示了如何执行此作:spring-doc.cadn.net.cn

槽的
org.springframework.cloud.contract.spec.Contract.make {
    request {
        method GET()
        url '/get'
    }
    response {
        status 200
        body 'Passed'
        fixedDelayMilliseconds 1000
    }
}
YML
response:
    fixedDelayMilliseconds: 1000
Java
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);
                // ...
            });
        }));
    }

}
kotlin
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract

contract {
    request {
        // ...
    }
    response {
        delay = fixedMilliseconds(1000)
        // ...
    }
}

2.6. XML HTTP 支持

对于 HTTP 协定,我们还支持在请求和响应正文中使用 XML。 XML 正文必须在body元素 作为StringGString.此外,还可以提供身体匹配器 请求和响应。代替jsonPath(…​)方法、org.springframework.cloud.contract.spec.internal.BodyMatchers.xPath方法,以及所需的xPath作为第一个参数提供 和适当的MatchingType作为 second。除了byType()受支持。spring-doc.cadn.net.cn

下面的示例展示了一个 Groovy DSL 契约,响应正文中带有 XML:spring-doc.cadn.net.cn

槽的
                    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())
                            }
                        }
                    }
YML
include::/tmp/releaser-1625584814123-0/spring-cloud-contract/spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_xml.yml
Java
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());
                });
            });
        });
    };

}
kotlin
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 测试:spring-doc.cadn.net.cn

@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. 一个文件中的多个合约

您可以在一个文件中定义多个合同。这样的合约可能类似于 以下示例:spring-doc.cadn.net.cn

槽的
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()
        }
    }
]
YML
---
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
Java
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 -> {
                // ...
            })
        );
    }

}
kotlin
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract

arrayOf(
    contract {
        name("should post a user")
        // ...
    },
    contract {
        // ...
    },
    contract {
        // ...
    }
}

在前面的示例中,一个合约具有name字段,而另一个则没有。这 导致生成两个测试,它们看起来或多或少类似于以下内容:spring-doc.cadn.net.cn

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.没有namefield 调用validate_withList_1.它对应于文件的名称WithList.groovy和 列表中合约的索引。spring-doc.cadn.net.cn

生成的存根如以下示例所示:spring-doc.cadn.net.cn

should post a user.json
1_WithList.json

第一个文件获取了name参数。第二个 获取了合约文件的名称 (WithList.groovy) 前缀为索引(在此 case,则合约的索引为1在文件的合同列表中)。spring-doc.cadn.net.cn

为您的合约命名要好得多,因为这样做会使 您的测试更有意义。

2.8. 有状态合约

有状态合同(也称为场景)是应该读取的合同定义 挨次。这在以下情况下可能很有用:spring-doc.cadn.net.cn

  • 您希望以精确定义的顺序执行合约,因为您使用了 Spring 用于测试有状态应用程序的 Cloud Contractspring-doc.cadn.net.cn

我们真的不鼓励你这样做,因为 Contract 测试应该是无状态的。

要创建有状态合约(或场景),您需要 在创建合同时使用正确的命名约定。约定 要求包含订单号,后跟下划线。这无论如何都有效 了解您是使用 YAML 还是 Groovy。下面的清单显示了一个示例:spring-doc.cadn.net.cn

my_contracts_dir\
  scenario1\
    1_login.groovy
    2_showCart.groovy
    3_logout.groovy

这样的树会导致 Spring Cloud Contract Verifier 生成 WireMock 的场景,其中包含 名称scenario1以及以下三个步骤:spring-doc.cadn.net.cn

  1. login,标记为Started指向...spring-doc.cadn.net.cn

  2. showCart,标记为Step1指向...spring-doc.cadn.net.cn

  3. logout,标记为Step2(这将关闭方案)。spring-doc.cadn.net.cn

您可以在 https://wiremock.org/docs/stateful-behaviour/ 中找到有关 WireMock 方案的更多详细信息。spring-doc.cadn.net.cn

3. 集成

3.1. JAX-RS 系列

Spring Cloud Contract 支持 JAX-RS 2 客户端 API。基类需要 定义protected WebTarget webTarget和服务器初始化。唯一的选项 测试 JAX-RS API 是为了启动 Web 服务器。此外,带有正文的请求需要有一个 content 类型。否则,默认的application/octet-stream被使用。spring-doc.cadn.net.cn

要使用 JAX-RS 模式,请使用以下设置:spring-doc.cadn.net.cn

testMode = 'JAXRSCLIENT'

以下示例显示了生成的测试 API:spring-doc.cadn.net.cn

                    """\
package com.example;

import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static javax.ws.rs.client.Entity.*;

@SuppressWarnings("rawtypes")
public class FooTest {
\tWebTarget webTarget;

\t@Test
\tpublic void validate_() throws Exception {

\t\t// when:
\t\t\tResponse response = webTarget
\t\t\t\t\t\t\t.path("/users")
\t\t\t\t\t\t\t.queryParam("limit", "10")
\t\t\t\t\t\t\t.queryParam("offset", "20")
\t\t\t\t\t\t\t.queryParam("filter", "email")
\t\t\t\t\t\t\t.queryParam("sort", "name")
\t\t\t\t\t\t\t.queryParam("search", "55")
\t\t\t\t\t\t\t.queryParam("age", "99")
\t\t\t\t\t\t\t.queryParam("name", "Denis.Stepanov")
\t\t\t\t\t\t\t.queryParam("email", "[email protected]")
\t\t\t\t\t\t\t.request()
\t\t\t\t\t\t\t.build("GET")
\t\t\t\t\t\t\t.invoke();
\t\t\tString responseAsString = response.readEntity(String.class);

\t\t// then:
\t\t\tassertThat(response.getStatus()).isEqualTo(200);

\t\t// and:
\t\t\tDocumentContext parsedJson = JsonPath.parse(responseAsString);
\t\t\tassertThatJson(parsedJson).field("['property1']").isEqualTo("a");
\t}

}

"""

3.2. 使用 WebTestClient 的 WebFlux

您可以使用 WebTestClient 来使用 WebFlux。以下清单显示了如何 配置 WebTestClient 作为测试模式:spring-doc.cadn.net.cn

Maven 系列
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>${spring-cloud-contract.version}</version>
    <extensions>true</extensions>
    <configuration>
        <testMode>WEBTESTCLIENT</testMode>
    </configuration>
</plugin>
Gradle
contracts {
        testMode = 'WEBTESTCLIENT'
}

以下示例演示如何设置 WebTestClient 基类和 RestAssure 对于 WebFlux:spring-doc.cadn.net.cn

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

public abstract class BeerRestBase {

    @Before
    public void setup() {
        RestAssuredWebTestClient.standaloneSetup(
        new ProducerController(personToCheck -> personToCheck.age >= 20));
    }
}
}
WebTestClient模式比EXPLICIT模式。

3.3. 具有显式模式的 WebFlux

您还可以在生成的测试中将 WebFlux 与 explicit 模式一起使用 以使用 WebFlux。以下示例演示如何使用显式模式进行配置:spring-doc.cadn.net.cn

Maven 系列
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>${spring-cloud-contract.version}</version>
    <extensions>true</extensions>
    <configuration>
        <testMode>EXPLICIT</testMode>
    </configuration>
</plugin>
Gradle
contracts {
        testMode = 'EXPLICIT'
}

以下示例显示了如何为 Web Flux 设置基类和 RestAssured:spring-doc.cadn.net.cn

@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 支持上下文路径。spring-doc.cadn.net.cn

完全支持上下文路径所需的唯一更改是 producer 端。此外,自动生成的测试必须使用显式模式。消费者 侧面保持不变。为了使生成的测试通过,您必须使用 explicit 模式。以下示例说明如何将测试模式设置为EXPLICIT:spring-doc.cadn.net.cn

Maven 系列
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>${spring-cloud-contract.version}</version>
    <extensions>true</extensions>
    <configuration>
        <testMode>EXPLICIT</testMode>
    </configuration>
</plugin>
Gradle
contracts {
        testMode = 'EXPLICIT'
}

这样,您可以生成一个不使用 MockMvc 的测试。这意味着您生成 real 请求,并且您需要设置生成的测试的基类才能在真实的 插座。spring-doc.cadn.net.cn

考虑以下合约:spring-doc.cadn.net.cn

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

以下示例演示如何设置基类和 RestAssured:spring-doc.cadn.net.cn

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

如果你这样做:spring-doc.cadn.net.cn

  • 自动生成的测试中的所有请求都会发送到真实终端节点,其中包含您的 上下文路径(例如,/my-context-path/url).spring-doc.cadn.net.cn

  • 您的合同反映您有一个上下文路径。您生成的存根还具有 该信息(例如,在 stub 中,您必须调用/my-context-path/url).spring-doc.cadn.net.cn

3.5. 使用 REST 文档

您可以使用 Spring REST Docs 生成 使用 Spring MockMvc 的 HTTP API 的文档(例如,以 Asciidoc 格式),WebTestClient或 RestAssure 的 Resassure 创建。在为 API 生成文档的同时,您还可以 使用 Spring Cloud Contract WireMock 生成 WireMock 存根。为此,请编写 普通的 REST Docs 测试用例和使用@AutoConfigureRestDocs使存根为 在 REST Docs 输出目录中自动生成。spring-doc.cadn.net.cn

REST 文档

以下示例使用MockMvc:spring-doc.cadn.net.cn

@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 应用程序)将如下所示:spring-doc.cadn.net.cn

@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 方法和除hostcontent-length.要匹配 request 更精确地(例如,为了匹配 POST 或 PUT 的正文),我们需要 显式创建请求匹配器。这样做有两个效果:spring-doc.cadn.net.cn

此功能的主要入口点是WireMockRestDocs.verify(),可以使用 作为document()convenience 方法,如下所示 示例显示:spring-doc.cadn.net.cn

import static org.springframework.cloud.contract.wiremock.restdocs.WireMockRestDocs.verify;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureRestDocs(outputDir = "target/snippets")
@AutoConfigureMockMvc
public class ApplicationTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void contextLoads() throws Exception {
        mockMvc.perform(post("/resource")
                .content("{\"id\":\"123456\",\"message\":\"Hello World\"}"))
                .andExpect(status().isOk())
                .andDo(verify().jsonPath("$.id")
                .andDo(document("resource"));
    }
}

前面的 Contract 指定任何带有idfield 接收响应 在此测试中定义。您可以将对.jsonPath()添加其他 匹配器。如果不熟悉 JSON 路径,则 JayWay 文档可以帮助您快速上手。这WebTestClient本测试的版本 具有类似的verify()static 辅助函数。spring-doc.cadn.net.cn

而不是jsonPathcontentType便捷方法,您还可以使用 WireMock API 验证请求是否与创建的存根匹配,因为 以下示例显示:spring-doc.cadn.net.cn

@Test
public void contextLoads() throws Exception {
    mockMvc.perform(post("/resource")
            .content("{\"id\":\"123456\",\"message\":\"Hello World\"}"))
            .andExpect(status().isOk())
            .andDo(verify()
                    .wiremock(WireMock.post(
                        urlPathEquals("/resource"))
                        .withRequestBody(matchingJsonPath("$.id"))
                        .andDo(document("post-resource"));
}

WireMock API 非常丰富。您可以通过以下方式匹配标头、查询参数和请求正文 regex 以及 JSON 路径。您可以使用这些功能创建具有更宽 参数范围。前面的示例生成类似于以下示例的存根:spring-doc.cadn.net.cn

post-resource.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在本节前面生成 available (例如,通过将存根发布为 JAR)。之后,您可以在 多种不同的方法,包括使用@AutoConfigureWireMock(stubs="classpath:resource.json"),如本文前面所述 公文。spring-doc.cadn.net.cn

3.5.1. 使用 REST Docs 生成合约

您还可以使用 Spring REST 生成 Spring Cloud Contract DSL 文件和文档 文档。如果与 Spring Cloud WireMock 结合使用执行此作,则会同时获得两个 Contract 和存根。spring-doc.cadn.net.cn

为什么要使用此功能?社区中的一些人提出了问题 关于他们希望转向基于 DSL 的合约定义的情况, 但是他们已经有很多 Spring MVC 测试了。使用此功能可让您生成 您稍后可以修改并移动到文件夹(在 configuration),以便插件找到它们。spring-doc.cadn.net.cn

您可能想知道为什么 WireMock 模块中有此功能。功能 存在,因为生成 Contract 和 Stub 都是有意义的。

请考虑以下测试:spring-doc.cadn.net.cn

        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()));

前面的测试会创建上一节中介绍的存根,并生成 合同和文档文件。spring-doc.cadn.net.cn

合约称为index.groovy,可能类似于以下示例:spring-doc.cadn.net.cn

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.spring-doc.cadn.net.cn

4. 消息

Spring Cloud Contract 允许您验证使用消息传递作为 通讯方式。本文档中展示的所有集成都可以与 Spring 一起使用。 但你也可以创建自己的一个并使用它。spring-doc.cadn.net.cn

4.1. 消息传递 DSL 顶级元素

用于消息传递的 DSL 看起来与专注于 HTTP 的 DSL 略有不同。这 以下部分解释了差异:spring-doc.cadn.net.cn

4.1.1. 由方法触发的输出

输出消息可以通过调用方法(例如Scheduler当合约是 started 并发送了一条消息),如以下示例所示:spring-doc.cadn.net.cn

槽的
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')
        }
    }
}
YML
# 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被执行。在消息发布者方面,我们生成一个 test 调用该方法来触发消息。在消费者方面,您可以使用 这some_label以触发消息。spring-doc.cadn.net.cn

4.1.2. 消息触发的输出

可以通过接收消息来触发输出消息,如下所示 例:spring-doc.cadn.net.cn

槽的
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')
        }
    }
}
YML
# 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目的地。在消息发布者方面,引擎 生成一个测试,将输入消息发送到定义的目标。在 Consumer 端,您可以向 Input 目标发送消息或使用标签 (some_label)触发消息。spring-doc.cadn.net.cn

4.1.3. 消费者/生产者

此部分仅对 Groovy DSL 有效。

在 HTTP 中,您有一个client/stub and `server/test表示法。您还可以 在消息传递中使用这些范例。此外,Spring Cloud Contract Verifier 还 提供consumerproducer方法,如以下示例所示 (请注意,您可以使用 OR$value方法提供consumerproducerparts):spring-doc.cadn.net.cn

                    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. 普通

inputoutputMessage部分,您可以调用assertThat替换为名称 的method(例如,assertThatMessageIsOnTheQueue()) 中定义的 基类或静态导入。Spring Cloud Contract 运行该方法 在生成的测试中。spring-doc.cadn.net.cn

4.2. 集成

您可以使用以下四种集成配置之一:spring-doc.cadn.net.cn

由于我们使用 Spring Boot,如果您已将这些库之一添加到 Classpath 中,则所有 消息收发配置是自动设置的。spring-doc.cadn.net.cn

记得把@AutoConfigureMessageVerifier在 生成的测试。否则,Spring Cloud Contract 的消息传递部分不会 工作。

如果要使用 Spring Cloud Stream,请记得在org.springframework.cloud:spring-cloud-stream-test-support如下:spring-doc.cadn.net.cn

Maven 系列
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-test-support</artifactId>
    <scope>test</scope>
</dependency>
Gradle
testCompile "org.springframework.cloud:spring-cloud-stream-test-support"

4.2.1. 手动集成测试

测试使用的主界面是org.springframework.cloud.contract.verifier.messaging.MessageVerifier. 它定义了如何发送和接收消息。您可以创建自己的实现 实现相同的目标。spring-doc.cadn.net.cn

在测试中,您可以注入ContractVerifierMessageExchange发送和接收 遵循 Contract 的消息。然后添加@AutoConfigureMessageVerifier进行测试。 以下示例显示了如何执行此作:spring-doc.cadn.net.cn

@RunWith(SpringTestRunner.class)
@SpringBootTest
@AutoConfigureMessageVerifier
public static class MessagingContractTests {

  @Autowired
  private MessageVerifier verifier;
  ...
}
如果您的测试也需要存根,则@AutoConfigureStubRunner包括 消息传送配置,因此您只需要一个注释。

4.3. 生产者端消息传递测试生成

使用inputoutputMessage部分会导致创建测试 在出版商方面。默认情况下,将创建 JUnit 4 测试。但是,还有一个 可以创建 JUnit 5、TestNG 或 Spock 测试。spring-doc.cadn.net.cn

我们应该考虑三种主要情况:spring-doc.cadn.net.cn

传递给messageFromsentTo可以具有不同的 不同消息传递实现的含义。对于 Stream 和 Integration,它是 首先解析为destination的频道。然后,如果没有这样的destination它被解析为通道名称。对于 Camel 来说,这是一个特定的组件(例如,jms).

4.3.1. 场景 1:无输入消息

考虑以下合约:spring-doc.cadn.net.cn

槽的
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())
        }
    }
}
YML
label: some_label
input:
  triggeredBy: bookReturnedTriggered
outputMessage:
  sentTo: activemq:output
  body:
    bookName: foo
  headers:
    BOOK-NAME: foo
    contentType: application/json

对于前面的示例,将创建以下测试:spring-doc.cadn.net.cn

JUnit
                    '''\
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:由输入触发的输出

考虑以下合约:spring-doc.cadn.net.cn

槽的
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')
        }
    }
}
YML
label: some_label
input:
  messageFrom: jms:input
  messageBody:
    bookName: 'foo'
  messageHeaders:
    sample: header
outputMessage:
  sentTo: jms:output
  body:
    bookName: foo
  headers:
    BOOK-NAME: foo

对于前面的 Contract,将创建以下测试:spring-doc.cadn.net.cn

JUnit
                    '''\
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:无输出消息

考虑以下合约:spring-doc.cadn.net.cn

槽的
def contractDsl = Contract.make {
    name "foo"
    label 'some_label'
    input {
        messageFrom('jms:delete')
        messageBody([
                bookName: 'foo'
        ])
        messageHeaders {
            header('sample', 'header')
        }
        assertThat('bookWasDeleted()')
    }
}
YML
label: some_label
input:
  messageFrom: jms:delete
  messageBody:
    bookName: 'foo'
  messageHeaders:
    sample: header
  assertThat: bookWasDeleted()

对于前面的 Contract,将创建以下测试:spring-doc.cadn.net.cn

JUnit
                    """\
package com.example;

import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.Test;
import org.junit.Rule;
import javax.inject.Inject;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage;
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes;

@SuppressWarnings("rawtypes")
public class FooTest {
\t@Inject ContractVerifierMessaging contractVerifierMessaging;
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;

\t@Test
\tpublic void validate_foo() throws Exception {
\t\t// given:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t"{\\"bookName\\":\\"foo\\"}"
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t);

\t\t// when:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:delete");
\t\t\tbookWasDeleted();

\t}

}

"""
斯波克
                    """\
package com.example

import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import javax.inject.Inject
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierObjectMapper
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage
import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static org.springframework.cloud.contract.verifier.messaging.util.ContractVerifierMessagingUtil.headers
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.fileToBytes

@SuppressWarnings("rawtypes")
class FooSpec extends Specification {
\t@Inject ContractVerifierMessaging contractVerifierMessaging
\t@Inject ContractVerifierObjectMapper contractVerifierObjectMapper

\tdef validate_foo() throws Exception {
\t\tgiven:
\t\t\tContractVerifierMessage inputMessage = contractVerifierMessaging.create(
\t\t\t\t\t'''{"bookName":"foo"}'''
\t\t\t\t\t\t, headers()
\t\t\t\t\t\t\t.header("sample", "header")
\t\t\t)

\t\twhen:
\t\t\tcontractVerifierMessaging.send(inputMessage, "jms:delete")
\t\t\tbookWasDeleted()

\t\tthen:
\t\t\tnoExceptionThrown()
\t}

}
"""

4.4. Consumer Stub 生成

与 HTTP 部分不同,在消息传递中,我们需要在 JAR 中发布合约定义,并且 一个存根。然后在使用者端对其进行解析,并创建适当的存根路由。spring-doc.cadn.net.cn

如果您在 Classpath 上有多个框架,则 Stub Runner 需要 定义应该使用哪一个。假设您拥有 AMQP、Spring Cloud Stream 和 Spring Integration 在 Classpath 上,并且您希望使用 Spring AMQP。然后你需要设置stubrunner.stream.enabled=falsestubrunner.integration.enabled=false. 这样,唯一剩下的框架就是 Spring AMQP。

4.4.1. 存根触发

要触发消息,请使用StubTrigger接口,如下例所示:spring-doc.cadn.net.cn

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,因此您只需要一个 或测试中的 other。spring-doc.cadn.net.cn

StubTrigger提供了以下触发消息的选项:spring-doc.cadn.net.cn

4.4.2. 按标签触发

以下示例显示如何触发带有标签的消息:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

4.4.3. 按组和工件 ID 触发

stubFinder.trigger('org.springframework.cloud.contract.verifier.stubs:streamService', 'return_book_1')

4.4.4. 由 Artifact ID 触发

以下示例显示如何从工件 ID 触发消息:spring-doc.cadn.net.cn

stubFinder.trigger('streamService', 'return_book_1')

4.4.5. 触发所有消息

以下示例演示如何触发所有消息:spring-doc.cadn.net.cn

stubFinder.trigger()

4.5. 使用 Apache Camel 进行消费者端消息传递

Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种与 Apache Camel 集成的简便方法。 对于提供的工件,它会自动下载存根并注册所需的 路线。spring-doc.cadn.net.cn

4.5.1. 将 Apache Camel 添加到项目中

您可以在 Classpath 上同时拥有 Apache Camel 和 Spring Cloud Contract Stub Runner。 请记住用@AutoConfigureStubRunner.spring-doc.cadn.net.cn

4.5.2. 禁用该功能

如果需要禁用此功能,请将stubrunner.camel.enabled=false财产。spring-doc.cadn.net.cn

4.5.3. 示例

假设我们有以下 Maven 存储库,其中包含为camelService应用。spring-doc.cadn.net.cn

└── .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

进一步假设存根包含以下结构:spring-doc.cadn.net.cn

├── META-INF
│   └── MANIFEST.MF
└── repository
    ├── accurest
    │   ├── bookDeleted.groovy
    │   ├── bookReturned1.groovy
    │   └── bookReturned2.groovy
    └── mappings

现在考虑以下合约(我们给它们编号 1 和 2):spring-doc.cadn.net.cn

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接口,如下所示:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

接下来,我们要监听发送到jms:output:spring-doc.cadn.net.cn

Exchange receivedMessage = consumerTemplate.receive('jms:output', 5000)

然后,收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.in.body)
receivedMessage.in.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)

由于路由已为您设置,因此您可以向jms:output目的地。spring-doc.cadn.net.cn

producerTemplate.
        sendBodyAndHeaders('jms:input', new BookReturned('foo'), [sample: 'header'])

接下来,我们要监听发送到jms:output如下:spring-doc.cadn.net.cn

Exchange receivedMessage = consumerTemplate.receive('jms:output', 5000)

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.in.body)
receivedMessage.in.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)

由于路由已为您设置,因此您可以向jms:outputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

producerTemplate.
        sendBodyAndHeaders('jms:delete', new BookReturned('foo'), [sample: 'header'])

4.6. 使用 Spring 集成的消费者端消息传递

Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring 集成。对于提供的工件,它会自动下载 存根并注册所需的路由。spring-doc.cadn.net.cn

4.6.1. 将 Runner 添加到项目中

您可以在 Spring Integration 和 Spring Cloud Contract Stub Runner 上同时拥有 classpath 的请记住用@AutoConfigureStubRunner.spring-doc.cadn.net.cn

4.6.2. 禁用该功能

如果需要禁用此功能,请将stubrunner.integration.enabled=false财产。spring-doc.cadn.net.cn

4.6.3. 示例

假设您有以下 Maven 存储库,其中包含为integrationService应用:spring-doc.cadn.net.cn

└── .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

进一步假设存根包含以下结构:spring-doc.cadn.net.cn

├── META-INF
│   └── MANIFEST.MF
└── repository
    ├── accurest
    │   ├── bookDeleted.groovy
    │   ├── bookReturned1.groovy
    │   └── bookReturned2.groovy
    └── mappings

考虑以下合约(编号 1 和 2):spring-doc.cadn.net.cn

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 集成路由:spring-doc.cadn.net.cn

<?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>

这些示例适用于三种情况:spring-doc.cadn.net.cn

场景 1 (无输入消息)

要从return_book_1标签中,使用StubTrigger接口,作为 遵循:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

下面的清单显示了如何侦听发送到jms:output:spring-doc.cadn.net.cn

Message<?> receivedMessage = messaging.receive('outputTest')

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)

由于路由已为您设置,因此您可以向jms:outputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

messaging.send(new BookReturned('foo'), [sample: 'header'], 'input')

下面的清单显示了如何侦听发送到jms:output:spring-doc.cadn.net.cn

Message<?> receivedMessage = messaging.receive('outputTest')

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)

由于路由已为您设置,因此您可以向jms:inputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')

4.7. 使用 Spring Cloud Stream 进行消费者端消息传递

Spring Cloud Contract Stub Runner 的消息传递模块为您提供了一种简单的方法 与 Spring Stream 集成。对于提供的工件,它会自动下载 stubs 并注册所需的路由。spring-doc.cadn.net.cn

如果 Stub Runner 与 Stream 的集成messageFromsentTo字符串 首先解析为destination的频道,但没有这样的destination存在,则 destination 解析为通道名称。

如果要使用 Spring Cloud Stream,请记得在org.springframework.cloud:spring-cloud-stream-test-support如下:spring-doc.cadn.net.cn

Maven 系列
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-test-support</artifactId>
    <scope>test</scope>
</dependency>
Gradle
testCompile "org.springframework.cloud:spring-cloud-stream-test-support"

4.7.1. 将 Runner 添加到项目中

您可以在 Spring Cloud Stream 和 Spring Cloud Contract Stub Runner 上同时拥有 classpath 的请记住用@AutoConfigureStubRunner.spring-doc.cadn.net.cn

4.7.2. 禁用该功能

如果需要禁用此功能,请将stubrunner.stream.enabled=false财产。spring-doc.cadn.net.cn

4.7.3. 示例

假设您有以下 Maven 存储库,其中包含为streamService应用:spring-doc.cadn.net.cn

└── .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

进一步假设存根包含以下结构:spring-doc.cadn.net.cn

├── META-INF
│   └── MANIFEST.MF
└── repository
    ├── accurest
    │   ├── bookDeleted.groovy
    │   ├── bookReturned1.groovy
    │   └── bookReturned2.groovy
    └── mappings

考虑以下合约(编号 1 和 2):spring-doc.cadn.net.cn

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 配置:spring-doc.cadn.net.cn

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

这些示例适用于三种情况:spring-doc.cadn.net.cn

场景 1 (无输入消息)

要从return_book_1标签中,使用StubTriggerinterface 设置为 遵循:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

以下示例说明如何侦听发送到其destinationreturnBook:spring-doc.cadn.net.cn

Message<?> receivedMessage = messaging.receive('returnBook')

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)

由于路由已为您设置,因此您可以向bookStorage destination如下:spring-doc.cadn.net.cn

messaging.send(new BookReturned('foo'), [sample: 'header'], 'bookStorage')

以下示例显示如何侦听发送到returnBook:spring-doc.cadn.net.cn

Message<?> receivedMessage = messaging.receive('returnBook')

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertJsons(receivedMessage.payload)
receivedMessage.headers.get('BOOK-NAME') == 'foo'
场景 3(输入无输出)

由于路由已为您设置,因此您可以向jms:outputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

messaging.send(new BookReturned('foo'), [sample: 'header'], 'delete')

4.8. 使用 Spring AMQP 进行消费者端消息传递

Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring AMQP 的 Rabbit Template 集成。对于提供的工件,它 自动下载存根并注册所需的路由。spring-doc.cadn.net.cn

集成尝试独立工作(即,不与正在运行的 RabbitMQ 消息代理)。它期望RabbitTemplate在应用程序上下文中,以及 将其用作名为@SpyBean.因此,它可以使用 Mockito 间谍 验证和检查应用程序发送的消息的功能。spring-doc.cadn.net.cn

在消息使用者端,存根运行程序会考虑所有@RabbitListener注释 endpoints 和所有SimpleMessageListenerContainer对象。spring-doc.cadn.net.cn

由于消息通常发送到 AMQP 中的交易所,因此消息协定包含 exchange name 作为目标。另一端的消息侦听器绑定到 队列。绑定将 exchange 连接到队列。如果触发了消息协定,则 Spring AMQP 存根运行程序集成在应用程序上下文上查找绑定 匹配此交换。然后,它从 Spring 交换中收集队列并尝试 查找绑定到这些队列的消息侦听器。系统会为所有匹配项触发该消息 消息侦听器。spring-doc.cadn.net.cn

如果需要使用路由密钥,可以使用amqp_receivedRoutingKey消息标头。spring-doc.cadn.net.cn

4.8.1. 将 Runner 添加到项目中

你可以在 Classpath 上同时拥有 Spring AMQP 和 Spring Cloud Contract Stub Runner,并且 设置stubrunner.amqp.enabled=true.请记住为您的测试类添加注释 跟@AutoConfigureStubRunner.spring-doc.cadn.net.cn

如果 Classpath 上已经有 Stream 和 Integration,则需要 要显式禁用它们,方法是将stubrunner.stream.enabled=falsestubrunner.integration.enabled=false性能。

4.8.2. 示例

假设您有以下 Maven 存储库,其中包含为spring-cloud-contract-amqp-test应用:spring-doc.cadn.net.cn

└── .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

进一步假设存根包含以下结构:spring-doc.cadn.net.cn

├── META-INF
│   └── MANIFEST.MF
└── contracts
    └── shouldProduceValidPersonData.groovy

然后考虑以下合约:spring-doc.cadn.net.cn

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 配置:spring-doc.cadn.net.cn

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
触发消息

要使用上一节中的协定触发消息,请使用StubTriggerinterface 设置为 遵循:spring-doc.cadn.net.cn

stubTrigger.trigger("contract-test.person.created.event")

消息的目标为contract-test.exchange,因此 Spring AMQP 存根运行程序 Integration 会查找与此 Exchange 相关的绑定,如下例所示:spring-doc.cadn.net.cn

@Bean
public Binding binding() {
    return BindingBuilder.bind(new Queue("test.queue"))
            .to(new DirectExchange("contract-test.exchange")).with("#");
}

绑定定义绑定名为test.queue.因此,以下侦听器 定义与 Contract 消息匹配并调用:spring-doc.cadn.net.cn

@Bean
public SimpleMessageListenerContainer simpleMessageListenerContainer(
        ConnectionFactory connectionFactory,
        MessageListenerAdapter listenerAdapter) {
    SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();
    container.setConnectionFactory(connectionFactory);
    container.setQueueNames("test.queue");
    container.setMessageListener(listenerAdapter);

    return container;
}

此外,以下带注释的侦听器匹配并被调用:spring-doc.cadn.net.cn

@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 在我们的测试期间尝试连接到正在运行的代理,我们 配置 mockConnectionFactory.spring-doc.cadn.net.cn

要禁用模拟的ConnectionFactory,设置以下属性:stubrunner.amqp.mockConnection=false如下:spring-doc.cadn.net.cn

stubrunner:
  amqp:
    mockConnection: false

4.9. 使用 Spring JMS 进行消费者端消息传递

Spring Cloud Contract Stub Runner 的消息传递模块提供了一种简单的方法 与 Spring JMS 集成。spring-doc.cadn.net.cn

该集成假定您有一个正在运行的 JMS 代理实例(例如activemq嵌入式代理)。spring-doc.cadn.net.cn

4.9.1. 将 Runner 添加到项目中

您需要在 Classpath 上同时具有 Spring JMS 和 Spring Cloud Contract Stub Runner。请记住为您的测试类添加注释 跟@AutoConfigureStubRunner.spring-doc.cadn.net.cn

4.9.2. 示例

假设存根结构如下所示:spring-doc.cadn.net.cn

├── stubs
    ├── bookDeleted.groovy
    ├── bookReturned1.groovy
    └── bookReturned2.groovy

进一步假设以下测试配置:spring-doc.cadn.net.cn

stubrunner:
  repository-root: stubs:classpath:/stubs/
  ids: my:stubs
  stubs-mode: remote
spring:
  activemq:
    send-timeout: 1000
  jms:
    template:
      receive-timeout: 1000

现在考虑以下合约(我们给它们编号 1 和 2):spring-doc.cadn.net.cn

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接口,如下所示:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

接下来,我们要监听发送到output:spring-doc.cadn.net.cn

TextMessage receivedMessage = (TextMessage) jmsTemplate.receive('output')

然后,收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.getText())
receivedMessage.getStringProperty('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)

由于路由已为您设置,因此您可以向output目的地。spring-doc.cadn.net.cn

jmsTemplate.
        convertAndSend('input', new BookReturned('foo'), new MessagePostProcessor() {
            @Override
            Message postProcessMessage(Message message) throws JMSException {
                message.setStringProperty("sample", "header")
                return message
            }
        })

接下来,我们要监听发送到output如下:spring-doc.cadn.net.cn

TextMessage receivedMessage = (TextMessage) jmsTemplate.receive('output')

收到的消息将传递以下断言:spring-doc.cadn.net.cn

receivedMessage != null
assertThatBodyContainsBookNameFoo(receivedMessage.getText())
receivedMessage.getStringProperty('BOOK-NAME') == 'foo'
场景 3(输入无输出)

由于路由已为您设置,因此您可以向outputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

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 集成。spring-doc.cadn.net.cn

该集成假定您有一个嵌入式 Kafka 代理的运行实例(通过spring-kafka-test依赖项)。spring-doc.cadn.net.cn

4.10.1. 将 Runner 添加到项目中

您需要同时拥有 Spring Kafka、Spring Kafka Test(要运行@EmbeddedBroker) 和 Spring Cloud Contract Stub Runner 的 API 中。请记住为您的测试类添加注释 跟@AutoConfigureStubRunner.spring-doc.cadn.net.cn

通过 Kafka 集成,为了轮询单个消息,我们需要在 Spring 上下文启动时注册一个消费者。这可能会导致这样一种情况:当您在使用者端时,Stub Runner 可以为相同的组 ID 和主题注册额外的使用者。这可能会导致只有一个组件实际轮询消息的情况。由于在消费者方面,您同时拥有 Spring Cloud Contract Stub Runner 和 Spring Cloud Contract Verifier Classpath,因此我们需要能够关闭此类行为。这是通过stubrunner.kafka.initializer.enabled标志,这将禁用 Contact Verifier 使用者注册。如果您的应用程序既是 kafka 消息的使用者又是生成者,则可能需要手动将该属性切换到false在生成的测试的基类中。spring-doc.cadn.net.cn

4.10.2. 示例

假设存根结构如下所示:spring-doc.cadn.net.cn

├── stubs
    ├── bookDeleted.groovy
    ├── bookReturned1.groovy
    └── bookReturned2.groovy

进一步假设以下测试配置(请注意spring.kafka.bootstrap-servers指向嵌入式代理的 IP via${spring.embedded.kafka.brokers}):spring-doc.cadn.net.cn

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-serializerspring.kafka.consumer.key-deserializer属性,因为 Kafka 反序列化需要非 null record keys 设置为 integer 类型。

现在考虑以下合约(我们给它们编号 1 和 2):spring-doc.cadn.net.cn

场景 1 (无输入消息)

要从return_book_1标签中,我们使用StubTrigger接口,如下所示:spring-doc.cadn.net.cn

stubFinder.trigger('return_book_1')

接下来,我们要监听发送到output:spring-doc.cadn.net.cn

Message receivedMessage = receiveFromOutput()

然后,收到的消息将传递以下断言:spring-doc.cadn.net.cn

assert receivedMessage != null
assert assertThatBodyContainsBookNameFoo(receivedMessage.getPayload())
assert receivedMessage.getHeaders().get('BOOK-NAME') == 'foo'
场景 2(输出由输入触发)

由于路由已为您设置,因此您可以向output目的地。spring-doc.cadn.net.cn

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如下:spring-doc.cadn.net.cn

Message receivedMessage = receiveFromOutput()
Message receivedMessage = receiveFromOutput()

收到的消息将传递以下断言:spring-doc.cadn.net.cn

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(输入无输出)

由于路由已为您设置,因此您可以向outputdestination 的 Destination 访问,如下所示:spring-doc.cadn.net.cn

Message message = MessageBuilder.createMessage(new BookReturned('foo'), new MessageHeaders([sample: "header",]))
kafkaTemplate.setDefaultTopic('delete')
kafkaTemplate.send(message)

5. Spring Cloud Contract Stub 运行器

您在使用 Spring Cloud Contract Verifier 时可能会遇到的问题之一是 将生成的 WireMock JSON 存根从服务器端传递到客户端(或传递给 各种客户端)。在消息传递的客户端生成方面也是如此。spring-doc.cadn.net.cn

复制 JSON 文件并手动设置客户端消息收发不在 问题。这就是我们引入 Spring Cloud Contract Stub Runner 的原因。它可以 自动下载并运行存根。spring-doc.cadn.net.cn

5.1. 快照版本

您可以将其他快照存储库添加到build.gradlefile 以使用快照 版本,这些版本在每次成功构建后自动上传,如下所示:spring-doc.cadn.net.cn

Maven 系列
<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>
Gradle
/*
 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 存储库中。spring-doc.cadn.net.cn

对于 Maven 和 Gradle,设置已准备就绪。但是,您可以自定义 如果你愿意,就去吧。

以下示例演示如何将存根发布为 jar:spring-doc.cadn.net.cn

Maven 系列
<!-- 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>
Gradle
ext {
    contractsDir = file("mappings")
    stubsOutputDirRoot = file("${project.buildDir}/production/${project.name}-stubs/")
}

// Automatically added by plugin:
// copyContracts - copies contracts to the output folder from which JAR will be created
// verifierStubsJar - JAR with a provided stub suffix
// the presented publication is also added by the plugin but you can modify it as you wish

publishing {
    publications {
        stubs(MavenPublication) {
            artifactId "${project.name}-stubs"
            artifact verifierStubsJar
        }
    }
}

5.3. Stub Runner 核心

存根运行器核心为服务协作者运行存根。将存根视为 services 允许您使用 stub-runner 作为 Consumer 驱动的 Contract 的实现。spring-doc.cadn.net.cn

Stub Runner 允许您自动下载提供的依赖项的存根(或 从 Classpath 中选择它们),为它们启动 WireMock 服务器,并为它们提供适当的 存根定义。对于消息传递,定义了特殊的存根路由。spring-doc.cadn.net.cn

5.3.1. 检索存根

您可以从以下获取存根的选项中进行选择:spring-doc.cadn.net.cn

  • 基于 Aether 的解决方案,可从 Artifactory 或 Nexus 下载带有存根的 JARspring-doc.cadn.net.cn

  • Classpath-scanning 解决方案,它使用模式搜索 Classpath 以检索存根spring-doc.cadn.net.cn

  • 编写您自己的org.springframework.cloud.contract.stubrunner.StubDownloaderBuilder用于完全定制spring-doc.cadn.net.cn

后一个示例在 Custom Stub Runner 部分中介绍。spring-doc.cadn.net.cn

下载 Stub

您可以使用stubsMode开关。它从StubRunnerProperties.StubsMode列举。您可以使用以下选项:spring-doc.cadn.net.cn

以下示例从本地位置选取存根:spring-doc.cadn.net.cn

@AutoConfigureStubRunner(repositoryRoot="https://foo.bar", ids = "com.example:beer-api-producer:+:stubs:8095", stubsMode = StubRunnerProperties.StubsMode.LOCAL)
类路径扫描

如果将stubsModeproperty 设置为StubRunnerProperties.StubsMode.CLASSPATH(或从CLASSPATH是默认值),则会扫描 Classpath。 请考虑以下示例:spring-doc.cadn.net.cn

@AutoConfigureStubRunner(ids = {
    "com.example:beer-api-producer:+:stubs:8095",
    "com.example.foo:bar:1.0.0:superstubs:8096"
})

您可以将依赖项添加到 Classpath 中,如下所示:spring-doc.cadn.net.cn

Maven 系列
<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>
Gradle
testCompile("com.example:beer-api-producer-restdocs:0.0.1-SNAPSHOT:stubs") {
    transitive = false
}
testCompile("com.example.thing1:thing2:1.0.0:superstubs") {
    transitive = false
}

然后扫描 Classpath 上的指定位置。为com.example:beer-api-producer-restdocs, 将扫描以下位置:spring-doc.cadn.net.cn

com.example.thing1:thing2,则会扫描以下位置:spring-doc.cadn.net.cn

在打包 producer stub 的

为了实现正确的存根打包,生产者将按如下方式设置 Contract:spring-doc.cadn.net.cn

└── src
    └── test
        └── resources
            └── contracts
                └── com.example
                    └── beer-api-producer-restdocs
                        └── nested
                            └── contract3.groovy

通过使用Maven 系列assembly插件Gradle Jar 任务,您必须创建以下内容 结构中:spring-doc.cadn.net.cn

└── META-INF
    └── com.example
        └── beer-api-producer-restdocs
            └── 2.0.0
                ├── contracts
                │   └── nested
                │       └── contract2.groovy
                └── mappings
                    └── mapping.json

通过维护此结构,可以扫描 Classpath,您可以从消息传递或 HTTP 存根,而无需下载构件。spring-doc.cadn.net.cn

配置 HTTP 服务器存根

Stub Runner 有一个HttpServerStub抽象出底层 HTTP 服务器的具体实现(例如,WireMock 是其中一种实现)。 有时,您需要对存根服务器执行一些额外的调整(这对于给定的实现是具体的)。 为此,Stub Runner 为您提供 这httpServerStubConfigurer属性,以及 JUnit 规则,可通过系统属性进行访问,您可以在其中提供 您对org.springframework.cloud.contract.stubrunner.HttpServerStubConfigurer接口。实现可以改变 给定 HTTP 服务器存根的配置文件。spring-doc.cadn.net.cn

Spring Cloud Contract Stub Runner 附带一个实现,您可以 可以扩展为 WireMock:org.springframework.cloud.contract.stubrunner.provider.wiremock.WireMockHttpServerStubConfigurer. 在configure方法 您可以为给定的存根提供自己的自定义配置。用途 case 可能会在 HTTPS 端口上为给定的项目 ID 启动 WireMock。以下内容 示例展示了如何做到这一点:spring-doc.cadn.net.cn

示例 1.WireMockHttpServerStubConfigurer 实现
@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注解,如下所示:spring-doc.cadn.net.cn

@AutoConfigureStubRunner(mappingsOutputFolder = "target/outputmappings/",
        httpServerStubConfigurer = HttpsForFraudDetection)

每当找到 HTTPS 端口时,它优先于 HTTP 端口。spring-doc.cadn.net.cn

5.3.2. 运行 stub

本节介绍如何执行 stub。它包含以下主题:spring-doc.cadn.net.cn

HTTP 存根

存根在 JSON 文档中定义,其语法在 WireMock 文档中定义spring-doc.cadn.net.cn

以下示例在 JSON 中定义一个存根:spring-doc.cadn.net.cn

{
    "request": {
        "method": "GET",
        "url": "/ping"
    },
    "response": {
        "status": 200,
        "body": "pong",
        "headers": {
            "Content-Type": "text/plain"
        }
    }
}
查看已注册的映射

每个存根协作者都会在__/admin/端点。spring-doc.cadn.net.cn

您还可以使用mappingsOutputFolder属性将映射转储到文件中。 对于基于注释的方法,它类似于以下示例:spring-doc.cadn.net.cn

@AutoConfigureStubRunner(ids="a.b.c:loanIssuance,a.b.c:fraudDetectionServer",
mappingsOutputFolder = "target/outputmappings/")

对于 JUnit 方法,它类似于以下示例:spring-doc.cadn.net.cn

@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文件夹中,您将看到以下结构;spring-doc.cadn.net.cn

.
├── fraudDetectionServer_13705
└── loanIssuance_12255

这意味着注册了两个存根。fraudDetectionServer在港口注册13705loanIssuance在港口12255.如果我们查看其中一个文件,我们将看到(对于 WireMock) 可用于给定服务器的映射:spring-doc.cadn.net.cn

[{
  "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,消息收发路由会自动设置。spring-doc.cadn.net.cn

5.4. Stub Runner JUnit 规则和 Stub Runner JUnit5 扩展

Stub Runner 附带一个 JUnit 规则,允许您下载和运行给定的 stub group 和工件 ID,如下例所示:spring-doc.cadn.net.cn

@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。StubRunnerRuleStubRunnerExtension以非常相似的方式工作。在规则或扩展名 执行时,Stub Runner 会连接到您的 Maven 存储库,并且对于给定的 依赖项尝试:spring-doc.cadn.net.cn

Stub Runner 使用 Eclipse Aether 机制下载 Maven 依赖项。 查看他们的文档以获取更多信息。spring-doc.cadn.net.cn

由于StubRunnerRuleStubRunnerExtension实现StubFinder他们让 您可以找到 Started Stubs,如下例所示:spring-doc.cadn.net.cn

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 的更多详细信息:spring-doc.cadn.net.cn

斯波克
@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()
}
JUnit 4
@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");
}
JUnit 5 系列
// Visible for Junit
@RegisterExtension
static StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
        .repoRoot(repoRoot()).stubsMode(StubRunnerProperties.StubsMode.REMOTE)
        .downloadStub("org.springframework.cloud.contract.verifier.stubs",
                "loanIssuance")
        .downloadStub(
                "org.springframework.cloud.contract.verifier.stubs:fraudDetectionServer")
        .withMappingsOutputFolder("target/outputmappingsforrule");

@BeforeAll
@AfterAll
static void setupProps() {
    System.clearProperty("stubrunner.repository.root");
    System.clearProperty("stubrunner.classifier");
}

private static String repoRoot() {
    try {
        return StubRunnerRuleJUnitTest.class.getResource("/m2repo/repository/")
                .toURI().toString();
    }
    catch (Exception e) {
        return "";
    }
}

有关更多信息,请参阅 JUnit 和 Spring 的通用属性 如何应用 Stub Runner 的全局配置。spring-doc.cadn.net.cn

要将 JUnit 规则或 JUnit 5 扩展与消息传递一起使用,您必须提供MessageVerifier规则生成器的界面(例如rule.messageVerifier(new MyMessageVerifier())). 如果不这样做,则每当尝试发送消息时,都会引发异常。

5.4.1. Maven 设置

存根下载程序遵循其他本地存储库文件夹的 Maven 设置。 当前不考虑存储库和配置文件的身份验证详细信息。 因此,您需要使用上述属性来指定它。spring-doc.cadn.net.cn

5.4.2. 提供固定端口

您还可以在固定端口上运行存根。您可以通过两种不同的方式来实现。 一种是在 properties 中传递,另一种是使用 Fluent API JUnit 规则。spring-doc.cadn.net.cn

5.4.3. Fluent API

使用StubRunnerRuleStubRunnerExtension,您可以添加要下载的存根 然后传递最后下载的存根的端口。以下示例显示了如何执行此作:spring-doc.cadn.net.cn

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

对于前面的示例,以下测试有效:spring-doc.cadn.net.cn

then(rule.findStubUrl("loanIssuance"))
        .isEqualTo(URI.create("http://localhost:12345").toURL());
then(rule.findStubUrl("fraudDetectionServer"))
        .isEqualTo(URI.create("http://localhost:12346").toURL());

5.4.4. 带弹簧的短管 Runner

Stub Runner with Spring 设置 Stub Runner 项目的 Spring 配置。spring-doc.cadn.net.cn

通过在配置文件中提供存根列表,Stub Runner 会自动下载 并在 WireMock 中注册选定的存根。spring-doc.cadn.net.cn

如果你想找到你的存根依赖项的 URL,你可以自动装配StubFinder接口和使用 其方法,如下所示:spring-doc.cadn.net.cn

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

这样做取决于以下配置文件:spring-doc.cadn.net.cn

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. 以下示例通过在 annotation 上设置值来获得相同的结果:spring-doc.cadn.net.cn

@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:thing1com.example:thing2:spring-doc.cadn.net.cn

您可以在代码中引用这些值。spring-doc.cadn.net.cn

您还可以使用@StubRunnerPort注解注入正在运行的存根的端口。 注解的值可以是groupid:artifactid或只是artifactid. 以下示例显示了com.example:thing1com.example:thing2.spring-doc.cadn.net.cn

@StubRunnerPort("thing1")
int thing1Port;
@StubRunnerPort("com.example:thing2")
int thing2Port;

5.5. Stub Runner Spring Cloud

Stub Runner 可以与 Spring Cloud 集成。spring-doc.cadn.net.cn

有关实际示例,请参阅:spring-doc.cadn.net.cn

5.5.1. Stubbing 服务发现

最重要的特点Stub Runner Spring Cloud是它存根的事实:spring-doc.cadn.net.cn

这意味着,无论您使用的是 Zookeeper、Consul、Eureka 还是任何东西 否则,您在测试中不需要它。我们正在启动您的 dependencies 的 Dependencies,并且我们会告诉您的应用程序,无论您何时使用Feign,以加载 平衡RestTemplateDiscoveryClient直接调用这些存根服务器 而不是调用真正的 Service Discovery 工具。spring-doc.cadn.net.cn

例如,以下测试通过:spring-doc.cadn.net.cn

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

请注意,前面的示例需要以下配置文件:spring-doc.cadn.net.cn

stubrunner:
  idsToServiceIds:
    ivyNotation: someValueInsideYourCode
    fraudDetectionServer: someNameThatShouldMapFraudDetectionServer
测试配置文件和服务发现

在集成测试中,您通常不想调用任何一个发现服务(例如 Eureka) 或 Config Server。因此,您需要创建一个额外的测试配置,并在其中禁用 这些功能。spring-doc.cadn.net.cn

由于spring-cloud-commons, 为此,您必须禁用这些属性 在如下例(对于 Eureka)的 static 块中:spring-doc.cadn.net.cn

    //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.enabledfalse您可以通过设置stubrunner.cloud.enabledfalsespring-doc.cadn.net.cn

默认情况下,所有服务发现都是存根的。这意味着,无论您是否拥有 现有的DiscoveryClient,则其结果将被忽略。但是,如果您想重用它,则可以设置stubrunner.cloud.delegate.enabledtrue,然后是现有的DiscoveryClient结果是 与存根的合并。

Stub Runner 使用的默认 Maven 配置可以调整 通过设置以下系统属性或设置相应的环境变量:spring-doc.cadn.net.cn

5.6. 使用 Stub Runner 引导应用程序

Spring Cloud Contract Stub Runner Boot 是一个 Spring Boot 应用程序,它将 REST 端点公开给 触发消息标签并访问 WireMock 服务器。spring-doc.cadn.net.cn

其中一个用例是在已部署的应用程序上运行一些冒烟(端到端)测试。 有关更多信息,您可以查看 Spring Cloud Pipelines 项目。spring-doc.cadn.net.cn

5.6.1. Stub Runner 服务器

要使用 Stub Runner Server,请添加以下依赖项:spring-doc.cadn.net.cn

compile "org.springframework.cloud:spring-cloud-starter-stub-runner"

然后用@EnableStubRunnerServer,构建一个胖罐子,然后就可以开始工作了。spring-doc.cadn.net.cn

有关属性,请参阅 Stub Runner Spring 部分。spring-doc.cadn.net.cn

5.6.2. Stub Runner 服务器 Fat Jar

您可以从 Maven 下载独立的 JAR(例如,对于版本 2.0.1.RELEASE) 通过运行以下命令:spring-doc.cadn.net.cn

$ 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 stubrunner.spring-doc.cadn.net.cn

为了传递配置,您可以创建一个stubrunner.yml当前工作目录中的文件, 在名为config~/.spring-cloud.该文件可能类似于以下内容 运行本地安装的存根的示例:spring-doc.cadn.net.cn

示例 2.stubrunner.yml
stubrunner:
  stubsMode: LOCAL
  ids:
    - com.example:beer-api-producer:+:9876

然后,您可以调用spring cloud stubrunner从终端窗口开始 Stub Runner 服务器。在港口有8750.spring-doc.cadn.net.cn

5.6.4. 端点

Stub Runner Boot 提供两个端点:spring-doc.cadn.net.cn

HTTP 协议

对于 HTTP,Stub Runner Boot 使以下终端节点可用:spring-doc.cadn.net.cn

  • 获取/stubs:返回 中所有正在运行的存根的列表ivy:integer表示法spring-doc.cadn.net.cn

  • 获取/stubs/{ivy}:返回给定ivy表示法(调用 Endpoint 时ivy也可以是artifactId仅)spring-doc.cadn.net.cn

消息

对于消息收发,Stub Runner Boot 使以下终端节点可用:spring-doc.cadn.net.cn

5.6.5. 示例

以下示例显示了 Stub Runner Boot 的典型用法:spring-doc.cadn.net.cn

@ContextConfiguration(classes = StubRunnerBoot, loader = SpringBootContextLoader)
@SpringBootTest(properties = "spring.cloud.zookeeper.enabled=false")
@ActiveProfiles("test")
class StubRunnerBootSpec extends Specification {

    @Autowired
    StubRunning stubRunning

    def setup() {
        RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning),
                new TriggerController(stubRunning))
    }

    def 'should return a list of running stub servers in "full ivy:port" notation'() {
        when:
            String response = RestAssuredMockMvc.get('/stubs').body.asString()
        then:
            def root = new JsonSlurper().parseText(response)
            root.'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs' instanceof Integer
    }

    def 'should return a port on which a [#stubId] stub is running'() {
        when:
            def response = RestAssuredMockMvc.get("/stubs/${stubId}")
        then:
            response.statusCode == 200
            Integer.valueOf(response.body.asString()) > 0
        where:
            stubId << ['org.springframework.cloud.contract.verifier.stubs:bootService:+:stubs',
                       'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs',
                       'org.springframework.cloud.contract.verifier.stubs:bootService:+',
                       'org.springframework.cloud.contract.verifier.stubs:bootService',
                       'bootService']
    }

    def 'should return 404 when missing stub was called'() {
        when:
            def response = RestAssuredMockMvc.get("/stubs/a:b:c:d")
        then:
            response.statusCode == 404
    }

    def 'should return a list of messaging labels that can be triggered when version and classifier are passed'() {
        when:
            String response = RestAssuredMockMvc.get('/triggers').body.asString()
        then:
            def root = new JsonSlurper().parseText(response)
            root.'org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs'?.containsAll(["delete_book", "return_book_1", "return_book_2"])
    }

    def 'should trigger a messaging label'() {
        given:
            StubRunning stubRunning = Mock()
            RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning), new TriggerController(stubRunning))
        when:
            def response = RestAssuredMockMvc.post("/triggers/delete_book")
        then:
            response.statusCode == 200
        and:
            1 * stubRunning.trigger('delete_book')
    }

    def 'should trigger a messaging label for a stub with [#stubId] ivy notation'() {
        given:
            StubRunning stubRunning = Mock()
            RestAssuredMockMvc.standaloneSetup(new HttpStubsController(stubRunning), new TriggerController(stubRunning))
        when:
            def response = RestAssuredMockMvc.post("/triggers/$stubId/delete_book")
        then:
            response.statusCode == 200
        and:
            1 * stubRunning.trigger(stubId, 'delete_book')
        where:
            stubId << ['org.springframework.cloud.contract.verifier.stubs:bootService:stubs', 'org.springframework.cloud.contract.verifier.stubs:bootService', 'bootService']
    }

    def 'should throw exception when trigger is missing'() {
        when:
            RestAssuredMockMvc.post("/triggers/missing_label")
        then:
            Exception e = thrown(Exception)
            e.message.contains("Exception occurred while trying to return [missing_label] label.")
            e.message.contains("Available labels are")
            e.message.contains("org.springframework.cloud.contract.verifier.stubs:loanIssuance:0.0.1-SNAPSHOT:stubs=[]")
            e.message.contains("org.springframework.cloud.contract.verifier.stubs:bootService:0.0.1-SNAPSHOT:stubs=")
    }

}

5.6.6. 带有服务发现的 Stub Runner 引导

使用 Stub Runner Boot 的一种方法是将其用作 “冒烟测试” 的存根源。那是什么意思? 假设您不想为了 Order Order 将 50 个微服务部署到测试环境中 以查看您的应用程序是否正常工作。您已经在构建过程中执行了一套测试, 但您还希望确保应用程序的打包有效。您可以 将应用程序部署到环境,启动它,然后对其运行几个测试以查看 它有效。我们可以将这些测试称为 “冒烟测试”,因为它们的目的是只检查少数几个 的测试场景。spring-doc.cadn.net.cn

这种方法的问题在于,如果您使用微服务,您很可能还会使用 使用服务发现工具。Stub Runner Boot 允许您通过启动 必需的存根,并在服务发现工具中注册它们。请考虑以下示例 使用 Eureka 进行这样的设置(假设 Eureka 已经在运行):spring-doc.cadn.net.cn

@SpringBootApplication
@EnableStubRunnerServer
@EnableEurekaClient
@AutoConfigureStubRunner
public class StubRunnerBootEurekaExample {

    public static void main(String[] args) {
        SpringApplication.run(StubRunnerBootEurekaExample.class, args);
    }

}

我们想要启动一个 Stub Runner Boot 服务器 (@EnableStubRunnerServer)、启用 Eureka 客户端 (@EnableEurekaClient), 并打开 Stub Runner 功能 (@AutoConfigureStubRunner).spring-doc.cadn.net.cn

现在假设我们要启动此应用程序,以便自动注册存根。 我们可以通过使用java -jar ${SYSTEM_PROPS} stub-runner-boot-eureka-example.jar哪里${SYSTEM_PROPS}包含以下属性列表:spring-doc.cadn.net.cn

* -Dstubrunner.repositoryRoot=https://repo.spring.io/snapshot (1)
* -Dstubrunner.cloud.stubbed.discovery.enabled=false (2)
* -Dstubrunner.ids=org.springframework.cloud.contract.verifier.stubs:loanIssuance,org.
* springframework.cloud.contract.verifier.stubs:fraudDetectionServer,org.springframework.
* cloud.contract.verifier.stubs:bootService (3)
* -Dstubrunner.idsToServiceIds.fraudDetectionServer=
* someNameThatShouldMapFraudDetectionServer (4)
*
* (1) - we tell Stub Runner where all the stubs reside (2) - we don't want the default
* behaviour where the discovery service is stubbed. That's why the stub registration will
* be picked (3) - we provide a list of stubs to download (4) - we provide a list of

这样,您部署的应用程序可以通过 service 向已启动的 WireMock 服务器发送请求 发现。最有可能的是,点 1 到 3 可以默认设置在application.yml,因为它们不是 可能会改变。这样,您可以只提供启动时要下载的存根列表 Stub Runner 靴子。spring-doc.cadn.net.cn

5.7. 消费者驱动的合约:每个消费者的存根

在某些情况下,同一终端节点的两个使用者希望有两个不同的响应。spring-doc.cadn.net.cn

此方法还可以让您立即知道哪个使用者使用您 API 的哪个部分。 您可以删除 API 生成的响应的一部分,并查看哪些自动生成的测试 失败。如果没有失败,您可以安全地删除响应的该部分,因为没有人使用它。

考虑以下为生产者定义的合约示例,该合约名为producer, 它有两个使用者 (foo-consumerbar-consumer):spring-doc.cadn.net.cn

消费者foo-service
request {
   url '/foo'
   method GET()
}
response {
    status OK()
    body(
       foo: "foo"
    }
}
消费者bar-service
request {
   url '/bar'
   method GET()
}
response {
    status OK()
    body(
       bar: "bar"
    }
}

您不能为同一请求生成两个不同的响应。这就是为什么你可以正确地打包 合约,然后从stubsPerConsumer特征。spring-doc.cadn.net.cn

在创建者端,使用者可以拥有一个文件夹,其中包含仅与他们相关的协定。 通过设置stubrunner.stubs-per-consumerflag 设置为true,我们不再注册所有 stub,而只注册那些 对应于使用者应用程序的名称。换句话说,我们扫描每个存根的路径,然后, 如果它包含路径中具有使用者名称的子文件夹,则只有这样才会注册它。spring-doc.cadn.net.cn

fooproducer 端,合约将如下所示spring-doc.cadn.net.cn

.
└── contracts
    ├── bar-consumer
    │   ├── bookReturnedForBar.groovy
    │   └── shouldCallBar.groovy
    └── foo-consumer
        ├── bookReturnedForFoo.groovy
        └── shouldCallFoo.groovy

bar-consumerconsumer 可以设置spring.application.namestubrunner.consumer-namebar-consumer或者,您可以按如下方式设置测试:spring-doc.cadn.net.cn

@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/…​文件夹)的 Id Broker 文件夹)。spring-doc.cadn.net.cn

您还可以显式设置使用者名称,如下所示:spring-doc.cadn.net.cn

@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/…​文件夹)的 Id Broker 文件夹)。spring-doc.cadn.net.cn

有关详细信息,请参阅第 224 期 有关此更改背后的原因的信息。spring-doc.cadn.net.cn

5.8. 从某个位置获取存根或 Contract 定义

而不是从 Artifactory / Nexus 或 Git,人们可以只想指向 驱动器或 Classpath 上的位置。这在多模块项目中特别有用,其中一个模块需要 重用来自另一个模块的存根或 Contract 而不 需要在本地 maven 中实际安装这些 存储库或将这些更改提交到 Git。spring-doc.cadn.net.cn

为了实现这一点,使用stubs://协议(当 repository root 参数设置为 在 Stub Runner 或 Spring Cloud Contract 插件中。spring-doc.cadn.net.cn

在此示例中,producer项目已成功 built 和 stub 在target/stubs文件夹。作为消费者,可以使用stubs://协议。spring-doc.cadn.net.cn

注解
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
        repositoryRoot = "stubs://file://location/to/the/producer/target/stubs/",
        ids = "com.example:some-producer")
JUnit 4 规则
@Rule
    public StubRunnerRule rule = new StubRunnerRule()
            .downloadStub("com.example:some-producer")
            .repoRoot("stubs://file://location/to/the/producer/target/stubs/")
            .stubsMode(StubRunnerProperties.StubsMode.REMOTE);
JUnit 5 扩展
@RegisterExtension
    public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
            .downloadStub("com.example:some-producer")
            .repoRoot("stubs://file://location/to/the/producer/target/stubs/")
            .stubsMode(StubRunnerProperties.StubsMode.REMOTE);

Contract 和 stub 可以存储在一个位置,每个 producer 都有自己的专用文件夹,用于 Contract 和 stub 映射。在该文件夹下,每个使用者都可以有自己的设置。要使 Stub Runner 从提供的 ID 中找到专用文件夹,可以传递一个属性stubs.find-producer=true或系统属性stubrunner.stubs.find-producer=true.spring-doc.cadn.net.cn

└── com.example (1)
    ├── some-artifact-id (2)
    │   └── 0.0.1
    │       ├── contracts (3)
    │       │   └── shouldReturnStuffForArtifactId.groovy
    │       └── mappings (4)
    │           └── shouldReturnStuffForArtifactId.json
    └── some-other-artifact-id (5)
        ├── contracts
        │   └── shouldReturnStuffForOtherArtifactId.groovy
        └── mappings
            └── shouldReturnStuffForOtherArtifactId.json
1 消费者的 Group ID
2 具有工件 ID [some-artifact-id] 的使用者
3 具有工件 ID [some-artifact-id] 的使用者的合同
4 具有工件 ID [some-artifact-id] 的使用者的映射
5 具有工件 ID [some-other-artifact-id] 的使用者
注解
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
        repositoryRoot = "stubs://file://location/to/the/contracts/directory",
        ids = "com.example:some-producer",
        properties="stubs.find-producer=true")
JUnit 4 规则
    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());
JUnit 5 扩展
    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. 在运行时生成存根

作为使用者,您可能不想等待创建者完成其实现,然后发布其存根。此问题的解决方案是在运行时生成存根。spring-doc.cadn.net.cn

作为创建者,在定义合同时,您需要使生成的测试通过,以便发布存根。在某些情况下,您希望取消阻止使用者,以便他们可以在测试实际通过之前获取存根。在这种情况下,您应该将此类合同设置为 in progress。您可以在 Contracts in Progress 部分阅读有关此内容的更多信息。这样,您的测试将不会生成,但会生成存根。spring-doc.cadn.net.cn

作为使用者,您可以切换开关以在运行时生成存根。Stub Runner 将忽略所有现有的存根映射,并将为所有合约定义生成新的存根映射。另一种选择是将stubrunner.generate-stubssystem 属性。您可以在下面找到此类设置的示例。spring-doc.cadn.net.cn

注解
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
        repositoryRoot = "stubs://file://location/to/the/contracts",
        ids = "com.example:some-producer",
        generateStubs = true)
JUnit 4 规则
@Rule
    public StubRunnerRule rule = new StubRunnerRule()
            .downloadStub("com.example:some-producer")
            .repoRoot("stubs://file://location/to/the/contracts")
            .stubsMode(StubRunnerProperties.StubsMode.REMOTE)
            .withGenerateStubs(true);
JUnit 5 扩展
@RegisterExtension
    public StubRunnerExtension stubRunnerExtension = new StubRunnerExtension()
            .downloadStub("com.example:some-producer")
            .repoRoot("stubs://file://location/to/the/contracts")
            .stubsMode(StubRunnerProperties.StubsMode.REMOTE)
            .withGenerateStubs(true);

5.10. 无桩失败

默认情况下,如果未找到存根,则 Stub Runner 将失败。为了改变该行为,只需设置为falsefailOnNoStubs属性或调用withFailOnNoStubs(false)方法。spring-doc.cadn.net.cn

注解
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
        repositoryRoot = "stubs://file://location/to/the/contracts",
        ids = "com.example:some-producer",
        failOnNoStubs = false)
JUnit 4 规则
@Rule
    public StubRunnerRule rule = new StubRunnerRule()
            .downloadStub("com.example:some-producer")
            .repoRoot("stubs://file://location/to/the/contracts")
            .stubsMode(StubRunnerProperties.StubsMode.REMOTE)
            .withFailOnNoStubs(false);
JUnit 5 扩展
@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. 通用属性

本节简要介绍常见的属性,包括:spring-doc.cadn.net.cn

5.11.1. JUnit 和 Spring 的通用属性

您可以使用系统属性或 Spring 配置来设置重复属性 性能。下表显示了它们的名称及其默认值:spring-doc.cadn.net.cn

属性名称 默认值 描述

stubrunner.minPortspring-doc.cadn.net.cn

10000spring-doc.cadn.net.cn

带有存根的已启动 WireMock 的端口最小值。spring-doc.cadn.net.cn

stubrunner.maxPortspring-doc.cadn.net.cn

15000spring-doc.cadn.net.cn

带有存根的已启动 WireMock 的端口的最大值。spring-doc.cadn.net.cn

stubrunner.repository根spring-doc.cadn.net.cn

Maven 存储库 URL。如果为空,则调用本地 Maven 存储库。spring-doc.cadn.net.cn

stubrunner.classifier 中spring-doc.cadn.net.cn

存根spring-doc.cadn.net.cn

存根工件的默认分类器。spring-doc.cadn.net.cn

stubrunner.stubs模式spring-doc.cadn.net.cn

类路径spring-doc.cadn.net.cn

你想要获取和注册存根的方式spring-doc.cadn.net.cn

stubrunner.ids 文件spring-doc.cadn.net.cn

要下载的 Ivy 表示法存根数组。spring-doc.cadn.net.cn

stubrunner.usernamespring-doc.cadn.net.cn

可选的 username 来访问存储 JAR 的工具 存根。spring-doc.cadn.net.cn

stubrunner.passwordspring-doc.cadn.net.cn

用于访问存储 JAR 的工具的可选密码 存根。spring-doc.cadn.net.cn

stubrunner.stubsPerConsumerspring-doc.cadn.net.cn

falsespring-doc.cadn.net.cn

设置为true如果要对 每个使用者,而不是为每个使用者注册所有存根。spring-doc.cadn.net.cn

stubrunner.consumer名称spring-doc.cadn.net.cn

如果您想为每个使用者使用一个存根,并且希望 覆盖使用者名称,更改此值。spring-doc.cadn.net.cn

5.11.2. Stub Runner 存根 ID

您可以在stubrunner.idssystem 属性。他们 使用以下模式:spring-doc.cadn.net.cn

groupId:artifactId:version:classifier:port

请注意,version,classifierport是可选的。spring-doc.cadn.net.cn

  • 如果您未提供port,则随机选择一个。spring-doc.cadn.net.cn

  • 如果您未提供classifier,则使用默认值。(请注意,您可以 以这种方式传递一个空分类器:groupId:artifactId:version:).spring-doc.cadn.net.cn

  • 如果您未提供version,则传递,最新的是 下载。+spring-doc.cadn.net.cn

port表示 WireMock 服务器的端口。spring-doc.cadn.net.cn

从版本 1.0.4 开始,您可以提供一系列版本,您可以 希望 Stub Runner 考虑在内。您可以阅读有关 Aether 版本控制的更多信息 范围。

6. Spring Cloud 合约 WireMock

Spring Cloud Contract WireMock 模块允许您在 Spring Boot 应用程序。查看示例了解更多详情。spring-doc.cadn.net.cn

如果您有一个使用 Tomcat 作为嵌入式服务器的 Spring Boot 应用程序(即 默认值为spring-boot-starter-web),您可以添加spring-cloud-starter-contract-stub-runner添加到您的 Classpath 中,并添加@AutoConfigureWireMock在测试中使用 Wiremock。Wiremock 作为存根服务器运行,而您 可以通过使用 Java API 或使用静态 JSON 声明作为 你的测试。下面的代码显示了一个示例:spring-doc.cadn.net.cn

@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-testproperty 设置为true.spring-doc.cadn.net.cn

6.1. 自动注册存根

如果您使用@AutoConfigureWireMock,它会从文件中注册 WireMock JSON 存根 system 或 classpath 中(默认情况下,从file:src/test/resources/mappings).您可以 使用stubs属性,可以是 Ant 样式的资源模式或目录。对于目录,*/.json是 附加。下面的代码显示了一个示例:spring-doc.cadn.net.cn

@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属性。要更改此行为,您可以 此外,指定 Files 根,如本文档的下一节所述。
此外,Git.stubslocation 不被视为 Wiremock 的“默认映射”的一部分,并且调用 自com.github.tomakehurst.wiremock.client.WireMock.resetToDefaultMappings在测试期间,不会导致映射 在stubs位置。但是,org.springframework.cloud.contract.wiremock.WireMockTestExecutionListener在每个测试类之后重置映射(包括从 stubs 位置添加映射),并且(可选)重置 在每个测试方法(由wiremock.reset-mappings-after-each-test属性)。

如果您使用 Spring Cloud Contract 的默认存根 jar,则您的 存根存储在/META-INF/group-id/artifact-id/versions/mappings/文件夹。 如果要从该位置注册所有嵌入式 JAR 中的所有存根,可以使用 以下语法:spring-doc.cadn.net.cn

@AutoConfigureWireMock(port = 0, stubs = "classpath*:/META-INF/**/mappings/**/*.json")

6.2. 使用文件指定存根体

WireMock 可以从 Classpath 或文件系统上的文件中读取响应体。在 的情况下,您可以在 JSON DSL 中看到响应具有bodyFileName而不是 (文字)body.这些文件是相对于根目录解析的(默认情况下,src/test/resources/__files).要自定义此位置,您可以设置files属性中的@AutoConfigureWireMockannotation 添加到父级的位置 目录(换句话说,__files是一个子目录)。您可以使用 Spring 资源 要引用的符号file:…​classpath:…​地点。通用 URL 不是 支持。可以给出值列表 — 在这种情况下,WireMock 解析第一个文件 当它需要查找响应正文时,它就存在了。spring-doc.cadn.net.cn

当您配置filesroot 的 Git 中,它还会影响 自动加载存根,因为它们来自根位置 在名为mappings.的值files没有 对从stubs属性。

6.3. 替代方案:使用 JUnit 规则

要获得更传统的 WireMock 体验,您可以使用 JUnit@Rules启动和停止 服务器。为此,请使用WireMockSpring便利类获取Optionsinstance,如下例所示:spring-doc.cadn.net.cn

@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表示服务器在完成此类中的所有方法后关闭 已运行。spring-doc.cadn.net.cn

6.4. Rest 模板的宽松 SSL 验证

WireMock 允许您使用httpsURL 协议。如果您的 应用程序想要在集成测试中联系该存根服务器,它会发现 SSL 证书无效(自行安装的证书的常见问题)。 最好的选择通常是重新配置客户端以使用http.如果那不是 选项,你可以要求 Spring 配置一个忽略 SSL 验证错误的 HTTP 客户端 (当然,仅对测试执行此作)。spring-doc.cadn.net.cn

要轻松完成这项工作,您需要使用 Spring BootRestTemplateBuilder在您的应用程序中,如下例所示:spring-doc.cadn.net.cn

@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
    return builder.build();
}

你需要RestTemplateBuilder因为构建器是通过回调传递给 初始化它,以便可以在此时在客户端中设置 SSL 验证。这 在测试中自动发生,如果您使用@AutoConfigureWireMockannotation 或 Stub Runner 的 Oper 中。如果您使用 JUnit@Rule方法,您需要添加@AutoConfigureHttpClient注解,如下例所示:spring-doc.cadn.net.cn

@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 客户端位于 classpath 中,它由RestTemplateBuilder并配置为忽略 SSL 错误。如果使用默认的java.netclient,您不需要注解(但它 没有坏处)。目前不支持其他客户端,但可能会添加 在将来的版本中。spring-doc.cadn.net.cn

要禁用自定义RestTemplateBuilder中,将wiremock.rest-template-ssl-enabledproperty 设置为false.spring-doc.cadn.net.cn

6.5. WireMock 和 Spring MVC 模拟

Spring Cloud Contract 提供了一个方便的类,可以将 JSON WireMock 存根加载到 弹簧MockRestServiceServer.下面的代码显示了一个示例:spring-doc.cadn.net.cn

@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();
    }

}

baseUrlvalue 被添加到所有 mock 调用的前面,并且stubs()method 接受一个存根 path 资源模式作为参数。在前面的示例中,在/stubs/resource.json加载到 mock 服务器中。如果RestTemplate被要求 访问example.org/,它将响应获取在该 URL 中声明的响应。更多 可以指定一个存根模式,并且每个存根模式可以是一个目录(对于递归的 全部列表.json)、固定文件名(如前面的示例所示)或 Ant 样式 模式。JSON 格式是普通的 WireMock 格式,您可以在 WireMock 网站上阅读有关该格式的信息。spring-doc.cadn.net.cn

目前,Spring Cloud Contract Verifier 支持 Tomcat、Jetty 和 Undertow 作为 Spring Boot 嵌入式服务器,而 Wiremock 本身对特定的 Jetty 版本(当前为 9.2)。要使用本机 Jetty,您需要将本机 Wiremock 依赖项并排除 Spring Boot 容器(如果有)。spring-doc.cadn.net.cn

7. 构建工具集成

您可以通过多种方式运行测试生成和存根执行。最常见的是 如下:spring-doc.cadn.net.cn

8. 下一步要读什么

如果您想了解有关本节中讨论的任何类的更多信息,可以直接浏览源代码。如果您有具体问题,请参阅作方法部分。spring-doc.cadn.net.cn

如果您对 Spring Cloud Contract 的核心功能感到满意,则可以继续阅读 关于 Spring Cloud Contract 的高级功能spring-doc.cadn.net.cn