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 社群所有即將舉行的活動。

檢視所有