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 调用中的拼写错误而失败更令人沮丧的事情了。

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

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

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

  • 契约:生产者和消费者之间关于通信应如何进行的约定。它不是一个模式(schema)。它更像是一个使用场景。例如,对于这个特定的场景,我期望一个指定的输入,然后我回复一个指定的输出。

  • 契约测试:一种验证生产者和消费者之间可以相互集成的测试。它不代表功能正常工作。这种区别很重要,因为您不会想通过为每个功能编写契约来重复工作。契约测试断言生产者和消费者之间的集成满足契约中定义的要求。它们的主要优点是快速且可靠。

以下示例显示了一个用 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`

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

  • 生产者契约测试方法中,生产者定义契约并编写契约测试,描述 API,并在不与其客户端进行任何协作的情况下发布存根。通常,当 API 是公开的且 API 所有者甚至不知道谁正在使用它时,就会发生这种情况。例如,Spring Initializr 通过 Spring Rest Docs 测试发布其存根。0.5.0.BUILD-SNAPSHOT 版本的存根可通过此处带有 stubs 分类器的文件获取。

  • 消费者驱动契约测试方法中,契约由消费者与生产者密切合作提出。生产者清楚地知道哪个消费者定义了哪个契约,以及当契约兼容性被破坏时哪个契约会受到影响。当使用内部 API 时,这种方法更常见。

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

Maven 术语介绍

由于现在使用 Spring Cloud Contract 处理非 JVM 项目变得容易得多,因此最好解释一下打包默认值背后的一些基本术语,并介绍一下 Maven 术语。

提示

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

(以下部分定义摘自Maven 术语表。)

  • Project(项目):Maven 以项目的形式进行思考。您构建的所有东西都是项目。这些项目遵循一个明确定义的“项目对象模型”(Project Object Model)。项目可以依赖于其他项目,在这种情况下,后者称为“依赖项”(dependencies)。一个项目可能由几个子项目组成。但是,这些子项目仍然被视为项目。

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

  • JAR:JAR 代表 Java ARchive(Java 归档)。它是一种基于 ZIP 文件格式的格式。Spring Cloud Contract 将契约和生成的存根打包在一个 JAR 文件中。

  • GroupId(组 ID):组 ID 是项目的全局唯一标识符。虽然这通常是项目名称(例如,commons-collections),但使用完全限定的包名称有助于将其与具有类似名称的其他项目区分开来(例如,org.apache.maven)。通常,当发布到 Artifact Manager 时,GroupId 会用斜杠分隔并构成 URL 的一部分。例如,对于 group ID com.exampleapplication 的 artifact ID 将是 /com/example/application/

  • Classifier(分类器):Maven 依赖项的表示法如下所示:groupId:artifactId:version:classifier。分类器是传递给依赖项的附加后缀(例如 stubssources)。同一个依赖项(例如 com.example:application)可以产生多个因分类器而彼此不同的构件。

  • Artifact manager(构件管理器):生成二进制文件、源代码或软件包后,您会希望其他人可以下载、引用或重用它们。在 JVM 世界中,这些构件是 JAR。对于 Ruby,它们是 gems。对于 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 运行存根。

以下部分将通过一个使用 Spring Cloud Contract 进行测试的 NodeJS 应用程序示例进行介绍。代码从 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 匹配的请求后,才能从存根化的 HTTP 服务器获取 2_shouldReturnListOfBooks.yml

重要提示

在实际示例中,我们会以契约测试模式运行 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 发送一个带有 Content-Type: application/json 头部和上述请求体的 POST 请求,则响应应该是 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:您的项目的 group ID。默认为 com.example

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

  • PROJECT_NAME:构件 ID。默认为 example

  • REPO_WITH_BINARIES_URL - 您的构件管理器的 URL。默认为 [http://localhost:8081/artifactory/libs-release-local](http://localhost: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 上运行图书应用程序的存根。为此,我们按如下方式使用存根运行 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" \
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" }' http://localhost:9876/api/books
# Now it's time for the second request
$ curl -X GET http://localhost: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 社区所有即将举行的活动。

查看全部