Spring Cloud Contract 在多语言世界中的应用

工程 | Marcin Grzejszczak | 2018年2月13日 | ...

本文简要回顾了什么是契约测试、Spring Cloud Contract 如何实现契约测试,以及 Spring Cloud Contract 如何在多语言世界中应用。

什么是契约测试

为了提高我们系统行为正确的确定性,我们会编写不同类型的测试。根据 测试金字塔,主要的测试类型是单元测试、集成测试和 UI 测试。测试越复杂,所需的时间和精力就越多,也越脆弱。

在分布式系统中,最常见的问题之一就是测试应用程序之间的集成。假设你的服务向另一个应用程序发送 REST 请求。在使用 Spring Boot 时,你可以编写一个 @SpringBootTest 来测试该行为。你设置了一个 Spring 上下文,准备了一个要发送的请求……然后你将它发送到哪里?你还没有启动另一个应用程序,所以你会得到一个 Connection Refused 异常。你可以尝试模拟真实的 HTTP 调用并返回一个伪造的响应。然而,如果你这样做,你就没有测试任何真实的 HTTP 集成、序列化和反序列化机制等等。你也可以启动一个伪造的 HTTP 服务器(例如,WireMock),并模拟它的行为。这里的问题在于,你作为 API 的客户端,定义了服务器的行为。换句话说,如果你告诉伪造的服务器当请求发送到 /myEndpoint 端点时返回文本 testText,它就会这样做,即使真实的服务器没有这样的端点。总之,问题在于存根可能并不可靠。

另一个问题是与第三方系统的集成。可能有一个共享的实例,由于高负载每 5 分钟就会崩溃一次。在这种情况下,我们希望将该系统存根化,使其不影响我们的集成测试,但我们需要这些存根是可靠的。

设置一个环境进行端到端测试,启动所有应用程序,并通过运行整个系统来进行测试,这总是很诱人的。通常,这是一个不错的解决方案,可以增加你对业务功能仍然运行良好的信心。然而,端到端测试的问题在于它们经常会无缘无故地失败,并且非常缓慢。没有什么比看到端到端测试运行了十个小时后,因为 API 调用中的一个拼写错误而失败更令人沮丧的了。

解决这个问题的潜在方法是契约测试。在我们详细介绍契约测试是什么之前,让我们先定义一些术语。

  • 生产者 (producer):服务器端所有者(例如,HTTP API 的所有者)或通过队列发送的消息的生产者,例如 RabbitMQ。

  • 消费者 (consumer):使用 HTTP API 或收听通过(例如)RabbitMQ 接收的消息的应用程序。

  • 契约 (contract):生产者和消费者之间关于通信应该是什么样子的协议。它不是一个模式。它更像是一个使用场景。例如,对于这个特定的场景,我期望一个指定的输入,然后我用一个指定的输出来响应。

  • 契约测试 (contract test):一个测试,用于验证生产者和消费者是否可以相互集成。这并不意味着功能有效。这个区别很重要,因为你不想为每个功能编写契约而重复工作。契约测试断言生产者和消费者之间的集成符合契约中定义的 reqirements。它们的主要优点是它们快速且可靠。

下面的示例展示了一个用 YAML 编写的契约。

request: # (1)
  method: PUT # (2)
  url: /fraudcheck # (3)
  body: # (4)
    "client.id": 1234567890
    loanAmount: 99999
  headers: # (5)
    Content-Type: application/json
  matchers:
    body:
      - path: $.['client.id'] # (6)
        type: by_regex
        value: "[0-9]{10}"
response: # (7)
  status: 200 # (8)
  body:  # (9)
    fraudCheckStatus: "FRAUD"
    "rejection.reason": "Amount too high"
  headers: # (10)
    Content-Type: application/json;charset=UTF-8


#From the Consumer perspective, when running a request in the integration test, we can interpret that test as follows:
#
#(1) - If the consumer sends a request
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a `client.id` field
# * has a `loanAmount` field that is equal to `99999`
#(5) - with `Content-Type` header equal to `application/json`
#(6) - and a `client.id` json entry matches a regular expression of `[0-9]{10}`
#(7) - then the response is sent with
#(8) - status equal to `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejection.reason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json`
#
#From the Producer perspective, in the autogenerated producer-side test, we can interpret that test as follows:
#
#(1) - A request is sent to the producer
#(2) - With the "PUT" method
#(3) - to the URL "/fraudcheck"
#(4) - with the JSON body that
# * has a `client.id` field with a value of `1234567890`
# * has a `loanAmount` field with a value of `99999`
#(5) - with a `Content-Type` header equal to `application/json`
#(7) - then the test asserts if the response has been sent with
#(8) - status equal `200`
#(9) - and a JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejection.reason": "Amount too high" }
#(10) - with a `Content-Type` header equal to `application/json;charset=UTF-8`

本文重点介绍两种主要的契约测试类型:生产者契约测试和消费者驱动契约测试。它们之间的主要区别在于生产者和消费者的协作方式。

  • 生产者契约测试方法中,生产者定义契约并编写契约测试,描述 API,并在不与其客户合作的情况下发布存根。这通常发生在 API 是公共的,并且 API 的所有者甚至不知道谁在使用它的时候。一个例子是 Spring Initializr,它通过 Spring Rest Docs 测试发布其存根。版本 0.5.0.BUILD-SNAPSHOT 的存根可以在 此处使用 stubs 分类器 找到。

  • 消费者驱动契约测试方法中,契约由消费者建议,并与生产者紧密合作。生产者确切地知道哪个消费者定义了哪个契约,以及在契约兼容性被破坏时哪个契约会断裂。这种方法在使用内部 API 时更为常见。

在这两种情况下,契约都可以定义在生产者的仓库中(通过 DSL 定义或编写契约测试),或者存储所有契约的外部仓库中。

Maven 命名法简介

由于现在在非 JVM 项目中使用 Spring Cloud Contract 更加容易,因此解释打包默认值背后的基本术语并介绍 Maven 命名法是很有益的。

提示

Apache Maven 是一个项目管理和理解工具。基于项目对象模型 (POM) 的概念,Maven 可以从一块集中的信息来管理项目的构建、报告和文档。(请参阅 https://maven.apache.org/

(以下定义部分摘自 Maven Glossary。)

  • 项目 (Project):Maven 以项目的概念来思考。你构建的所有东西都是项目。这些项目遵循一个定义明确的“项目对象模型”。项目可以依赖于其他项目,在这种情况下,后者被称为“依赖项”。一个项目可能包含几个子项目。然而,这些子项目仍然被视为项目。

  • 构件 (Artifact):构件是项目产生或使用的东西。Maven 为项目生成的构件示例包括 JAR 文件以及源代码和二进制分发包。每个构件都由组 ID 和构件 ID 唯一标识,在组内是唯一的。

  • JAR:JAR 是 Java ARchive 的缩写。它是一种基于 ZIP 文件格式的格式。Spring Cloud Contract 将契约和生成的存根打包到一个 JAR 文件中。

  • GroupId:组 ID 是项目的唯一标识符。虽然这通常是项目名称(例如,commons-collections),但使用完整的包名有助于将其与其他同名项目区分开来(例如,org.apache.maven)。通常,当发布到构件管理器时,GroupId 会被斜杠分隔并构成 URL 的一部分。例如,对于组 ID com.exampleapplication 的构件 ID 将是 /com/example/application/

  • Classifier:Maven 依赖项的表示法如下:groupId:artifactId:version:classifier。分类器是传递给依赖项的附加后缀(例如,stubssources)。相同的依赖项(例如,com.example:application)可以产生多个构件,它们通过分类器相互区分。

  • 构件管理器 (Artifact manager):当你生成二进制文件、源代码或包时,你希望它们可供其他人下载、引用或重用。在 JVM 世界中,这些构件将是 JAR 文件。对于 Ruby,它们将是 gem。对于 Docker,它们将是 Docker 镜像。你可以将这些构件存储在一个管理器中。此类管理器的示例包括 ArtifactoryNexus

什么是 Spring Cloud Contract

Spring Cloud Contract 是一个旨在帮助用户实现各种契约测试的解决方案的伞形项目。它包含两个主要模块:Spring Cloud Contract Verifier,主要由生产者端使用;Spring Cloud Contract Stub Runner,由消费者端使用。

该项目允许你使用以下方式定义契约:

假设我们决定使用 YAML 编写契约。在生产者端,从契约开始

  • 使用 Maven 或 Gradle 插件生成测试,以断言契约得到满足。

  • 生成存根供其他项目重用。

对于使用 Spring Cloud Contract 和 YAML 契约的 JVM 应用程序,生产者契约方法的简化流程如下。

生产者

  • 应用 Maven 或 Gradle Spring Cloud Contract 插件。

  • src/test/resources/contracts/ 下定义 YAML 契约。

  • 从契约生成测试和存根。

  • 创建一个继承自生成的测试的基类,并设置测试上下文。

  • 测试通过后,创建一个带有 stubs 分类器的 JAR,其中存储了契约和存根。

  • 将带有 stubs 分类器的 JAR 上传到二进制存储。

消费者

  • 使用 Stub Runner 获取生产者的存根。Stub Runner 启动内存中的 HTTP 服务器(默认是 WireMock 服务器),并加载存根。

  • 针对存根运行测试。

因此,使用 Spring Cloud Contract 和契约测试可以给你带来:

  • 存根的可靠性:它们仅在测试通过后才生成。

  • 存根的可重用性:它们可以被多个消费者下载和重用。

Spring Cloud Contract 的当前“问题”

分布式系统由用不同语言和框架编写的应用程序组成。Spring Cloud Contract 的一个“问题”是 DSL 必须用 Groovy 编写。尽管契约不需要任何特殊的语言知识,但对于非 JVM 用户来说,这成了一个问题。

在生产者端,Spring Cloud Contract 生成 Java 或 Groovy 的测试。当然,在非 JVM 环境中使用这些测试成了一个问题。你不仅需要安装 Java,而且测试是由 Maven 或 Gradle 插件生成的,这需要使用这些构建工具。

Spring Cloud Contract 和多语言支持

Edgware.SR2 发行版和 Spring Cloud Contract 的 1.2.3.RELEASE 开始,我们决定添加功能以允许 Spring Cloud Contract 在非 JVM 世界中得到更广泛的应用。

我们添加了对使用 YAML 编写契约的支持。YAML 是一种(又一种)标记语言,它不绑定到任何特定语言,并且已经得到了广泛使用。这应该解决了使用与任何特定语言相关的 DSL 定义契约的“问题”。

为了隐藏实现细节(如 Java 测试的生成、插件设置或 Java 安装),我们需要引入一个抽象层。我们决定通过使用 Docker 镜像来隐藏这些细节。我们将所有项目设置、所需包和文件夹结构封装在一个 Docker 镜像中,这样用户除了必需的环境变量外,不需要任何其他知识。

我们为 生产者消费者 引入了 Docker 镜像。所有 JVM 相关的逻辑都封装在 Docker 容器中,这意味着你不需要安装 Java 来生成测试并使用 Stub Runner 运行存根。

接下来的部分将通过一个 NodeJS 应用程序的示例,说明如何使用 Spring Cloud Contract 进行测试。代码是从 https://github.com/bradtraversy/bookstore 分叉的,可以在 https://github.com/spring-cloud-samples/spring-cloud-contract-nodejs 找到。我们的目标是以最少的精力,尽快为现有应用程序生成测试和存根。

Spring Cloud Contract 在生产者端的应用

让我们克隆简单的 NodeJS MVC 应用程序,如下所示。

$ git clone https://github.com/spring-cloud-samples/spring-cloud-contract-nodejs
$ cd spring-cloud-contract-nodejs

它连接到一个 Mongo DB 数据库来存储书籍数据。

YAML 契约在 /contracts 文件夹下可用,如下所示。

$ ls contracts
1_shouldAddABook.yml          2_shouldReturnListOfBooks.yml

数字后缀告诉 Spring Cloud Contract 从这些契约生成的测试需要按顺序执行。存根是状态化的,这意味着,只有在执行了与 1_shouldAddABook 匹配的请求后,2_shouldReturnListOfBooks.yml 才能从存根化的 HTTP 服务器获得。

重要

在实际示例中,我们将在契约测试模式下运行 NodeJS 应用程序,其中数据库调用将被存根化,并且不需要状态化存根。在此示例中,我们希望在短时间内展示 Spring Cloud Contract 的好处。

让我们看一下其中一个存根。

description: |
  Should add a book
request:
  method: POST
  url: /api/books
  headers:
    Content-Type: application/json
  body: '{
    "title" : "Title",
    "genre" : "Genre",
    "description" : "Description",
    "author" : "Author",
    "publisher" : "Publisher",
    "pages" : 100,
    "image_url" : "https://d213dhlpdb53mu.cloudfront.net/assets/pivotal-square-logo-41418bd391196c3022f3cd9f3959b3f6d7764c47873d858583384e759c7db435.svg",
    "buy_url" : "https://pivotal.io"
  }'
response:
  status: 200

契约规定,如果向 /api/books 发送一个 POST 请求,并带有 Content-Type: application/json 的头信息和上述请求体,那么响应应该是 200。现在,在运行契约测试之前,让我们分析一下 Spring Cloud Contract Docker 镜像的要求。

Spring Cloud Contract Docker 镜像

该镜像可在 DockerHub 的 SpringCloud 组织下找到。

一旦你挂载了你的契约并传入了环境变量,该镜像就会

  • 生成契约测试。

  • 针对提供的 URL 执行测试。

  • 生成 WireMock 存根。

  • 将存根发布到构件管理器。(此步骤是可选的,但默认启用。)

重要

生成的测试假定你的应用程序正在运行,并准备好监听指定端口的请求。这意味着在运行契约测试之前,你必须先启动它。

Spring Cloud Contract Docker 镜像设置

Docker 镜像在 /contracts 文件夹下查找契约。运行测试的输出可在 /spring-cloud-contract/build 文件夹下找到(这对于调试很有用)。在运行构建时,你需要挂载这些卷。

Docker 镜像还需要一些环境变量,用于指向你的运行应用程序、构件管理器实例等,如下列表所述:

  • PROJECT_GROUP:你的项目的组 ID。默认为 com.example

  • PROJECT_VERSION:你的项目的版本。默认为 0.0.1-SNAPSHOT

  • PROJECT_NAME。构件 ID。默认为 example

  • REPO_WITH_BINARIES_URL - 你的构件管理器的 URL。默认为 [https://:8081/artifactory/libs-release-local](https://:8081/artifactory/libs-release-local),这是 Artifactory 本地运行时默认的 URL。

  • REPO_WITH_BINARIES_USERNAME:(可选)当构件管理器受保护时的用户名。

  • REPO_WITH_BINARIES_PASSWORD:(可选)当构件管理器受保护时的密码。

  • PUBLISH_ARTIFACTS:如果设置为 true,则将构件发布到二进制存储。默认为 true

运行测试时使用以下环境变量:

  • APPLICATION_BASE_URL:应执行测试的 URL。请记住,它必须可以从 Docker 容器访问(localhost 不起作用)。

  • APPLICATION_USERNAME:(可选)用于应用程序基本身份验证的用户名。

  • APPLICATION_PASSWORD:(可选)用于应用程序基本身份验证的密码。

在生产者端运行 Spring Cloud Contract 测试

重要

要运行此示例,你需要安装 DockerDocker Composenpm

由于我们想运行测试,我们可以使用

$ npm install
$ npm test

但是,出于学习目的,让我们将其分解成几个部分,如下所示(我们将分析 bash 脚本的每一行)。

# Install the required npm packages
$ npm install

# Stop docker infra (mongodb, artifactory)
$ ./stop_infra.sh
# Start docker infra (mongodb, artifactory)
$ ./setup_infra.sh

# Kill & Run app
$ pkill -f "node app"
$ nohup node app &

# Prepare environment variables
$ export SC_CONTRACT_DOCKER_VERSION="1.2.3.RELEASE"
$ export APP_IP="192.168.0.100" # This has to be the IP that is available outside of Docker container
$ export APP_PORT="3000"
$ export ARTIFACTORY_PORT="8081"
$ export APPLICATION_BASE_URL="http://${APP_IP}:${APP_PORT}"
$ export ARTIFACTORY_URL="http://${APP_IP}:${ARTIFACTORY_PORT}/artifactory/libs-release-local"
$ export CURRENT_DIR="$( pwd )"
$ export PROJECT_NAME="bookstore"
$ export PROJECT_GROUP="com.example"
$ export PROJECT_VERSION="0.0.1.RELEASE"

# Execute contract tests
$ docker run  --rm -e "APPLICATION_BASE_URL=${APPLICATION_BASE_URL}" \
-e "PUBLISH_ARTIFACTS=true" -e "PROJECT_NAME=${PROJECT_NAME}" \
-e "PROJECT_GROUP=${PROJECT_GROUP}" -e "REPO_WITH_BINARIES_URL=${ARTIFACTORY_URL}" \
-e "PROJECT_VERSION=${PROJECT_VERSION}" -v "${CURRENT_DIR}/contracts/:/contracts:ro" \
-v "${CURRENT_DIR}/node_modules/spring-cloud-contract/output:/spring-cloud-contract-output/" \
springcloud/spring-cloud-contract:"${SC_CONTRACT_DOCKER_VERSION}"

# Kill app
$ pkill -f "node app"

发生的情况是,通过 bash 脚本

总而言之,我们定义了 YAML 契约,运行了 NodeJS 应用程序,并运行了 Docker 镜像来生成契约测试和存根,并将它们上传到 Artifactory。

在消费者端使用 Spring Cloud Contract 存根

在此示例中,我们发布了一个 spring-cloud/spring-cloud-contract-stub-runner Docker 镜像,该镜像启动了 Stub Runner 的独立版本。

提示

如果你更喜欢运行 java -jar 命令而不是运行 Docker,你可以从 Maven 下载一个独立的 JAR 文件(例如,对于 1.2.3.RELEASE 版本),如下所示:wget -O stub-runner.jar 'https://search.maven.org/remote_content?g=org.springframework.cloud&a=spring-cloud-contract-stub-runner-boot&v=1.2.3.RELEASE'

你可以将任何 属性 作为环境变量传递。约定是所有字母都应大写,并且单词分隔符和点(.)应被下划线(_)替换。例如,stubrunner.repositoryRoot 属性应表示为 STUBRUNNER_REPOSITORY_ROOT 环境变量。

假设我们想在端口 9876 上运行 bookstore 应用程序的存根。为此,让我们运行带有存根的 Stub Runner Boot 应用程序,如下所示:

# Provide the Spring Cloud Contract Docker version
$ export SC_CONTRACT_DOCKER_VERSION="1.2.3.RELEASE"
# The IP at which the app is running and the Docker container can reach it
$ export APP_IP="192.168.0.100"
# Spring Cloud Contract Stub Runner properties
$ export STUBRUNNER_PORT="8083"
# Stub coordinates 'groupId:artifactId:version:classifier:port'
$ export STUBRUNNER_IDS="com.example:bookstore:0.0.1.RELEASE:stubs:9876"
$ export STUBRUNNER_REPOSITORY_ROOT="http://${APP_IP}:8081/artifactory/libs-release-local"
# Run the docker with Stub Runner Boot
$ docker run  --rm -e "STUBRUNNER_IDS=${STUBRUNNER_IDS}" \
-e "STUBRUNNER_REPOSITORY_ROOT=${STUBRUNNER_REPOSITORY_ROOT}" \
-p "${STUBRUNNER_PORT}:${STUBRUNNER_PORT}" -p "9876:9876" -v "${HOME}/.m2/:/home/scc/.m2 \
springcloud/spring-cloud-contract-stub-runner:"${SC_CONTRACT_DOCKER_VERSION}"

该脚本

  • 启动一个独立的 Spring Cloud Contract Stub Runner 应用程序。

  • 导致 Stub Runner 下载具有以下坐标的存根:com.example:bookstore:0.0.1.RELEASE:stubs

  • 从 Artifactory 下载存根:[http://192.168.0.100:8081/artifactory/libs-release-local](http://192.168.0.100:8081/artifactory/libs-release-local)

  • 在延迟后(默认为 8083)启动 Stub Runner 端口。

  • 在端口 9876 上运行存根。

在服务器端,我们构建了一个有状态的存根。让我们使用 curl 来断言存根设置正确,如下所示:

# let's execute the first request (no response is returned)
$ curl -H "Content-Type:application/json" -X POST \
--data '{ "title" : "Title", "genre" : "Genre", "description" : "Description", "author" : "Author", "publisher" : "Publisher", "pages" : 100, "image_url" : "https://d213dhlpdb53mu.cloudfront.net/assets/pivotal-square-logo-41418bd391196c3022f3cd9f3959b3f6d7764c47873d858583384e759c7db435.svg", "buy_url" : "https://pivotal.io" }' https://:9876/api/books
# Now it's time for the second request
$ curl -X GET https://:9876/api/books
# You should receive the contents of the JSON

总而言之,一旦存根上传完毕,你就可以使用一些环境变量运行一个 Docker 镜像,并在你的集成测试中重用它们,而不考虑所使用的编程语言。

总结

在这篇博文中,我们解释了什么是契约测试以及为什么它们很重要。我们介绍了如何使用 Spring Cloud Contract 来生成和执行契约测试。最后,我们通过一个示例,说明了如何为非 JVM 应用程序使用 Spring Cloud Contract 的生产者和消费者的 Docker 镜像。

其他资源

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有