Spring Cloud ContractでCDCする
はじめに
マイクロサービスで、E2Eテストを書く際にマイクロサービスが依存するマイクロサービスを立ち上げるのが面倒だったり、時には現実的では無い場合があるなぁと最近感じてます。
そんな中でConsumer Driven Contract(CDC)という考え方を耳にはさみ、調べてみるとSpring Cloud Contractと言うプロジェクトでCDCをサポートしているということを知り、その機能を試して見ようと思います。
このブログでは以下のことを目指します。
- CDCの概要を学ぶ
- Spring Cloud Contractの基本的な機能を利用してCDCしてみる
Consumer Driven Contract(CDC)とは?
マイクロサービスアーキテクチャにおいて、マイクロサービスAのE2Eテストしたいと考えた際に、マイクロサービスAがマイクロサービスBに依存していて、更にそのマイクロサービスAがマイクロサービスC、D、Eに依存していると仮定します。
その際に、テストしたいのはマイクロサービスAなのにC、D、Eまで立てる必要があります。
この例は単純ですが、例えば1000個からなるマイクロサービスを考えた場合に依存するマイクロサービスすべてを立ててE2Eテストを行なうことは時には現実的では無い場合があると思います。
CDCではそのような課題を解決します。
CDCではConsumer(クライアント)とProducer(サーバ)の間でContract(契約)を結び、Producerでそれぞれその契約に従っているかどうかのテストを行います。
Producerが契約に従っているという原則のもとProducerをスタブにしてしまっても良いというのがCDCのアイディアです。
自分の理解ですが、この契約は一般的にはWebのインターフェースとなるケースが多いと理解しています。
Spring Cloud Contract
Spring Cloud ContractはCDCのアプローチに対してサポートを行っているSpring Cloud傘下のプロジェクトです。 Spring Cloud Contractでは以下のことを目指しています。
- ATDD(acceptance test-driven developement)とマイクロサービスアーキテクチャをプロモーションする
- Contractの変更を即座に公開できる方法を提供する
- サーバサイドのテストコードで、ボイラープレートになる部分を自動生成する
- HTTPやメッセージングのスタブを自動生成し、Producerが返す値と必ず一致していることを確認する
Spring Cloud Contractは使ってJVM言語でのCDCを可能とします。
多言語のでの利用する際のプラクティスもあるようですが(Spring Cloud Contract in a polyglot world)、今回は一旦触れないで起きます。
Contractとは何か?
Consumerのサービスとして、Producerに何を期待するかを記述し、Producerが受け入れたものがCDCにおけるContractとなります。
Spring Cloud Contractではyamlか、groovyでContractを記述することが可能です。
このブログではyamlでの契約の記述をしてみようと思います。
使ってみる
Spring Bootでプロジェクトを作成します。
また、言語はJavaで書きます。
プロジェクトはConsumerとProducerの2つを作ります。
環境
動作環境は以下の通り
$ uname -srvmpio Linux 5.3.0-53-generic #47~18.04.1-Ubuntu SMP Thu May 7 13:10:50 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a LSB Version: core-9.20170808ubuntu1-noarch:security-9.20170808ubuntu1-noarch Distributor ID: Ubuntu Description: Ubuntu 18.04.4 LTS Release: 18.04 Codename: bionic $ java -version openjdk version "11.0.7" 2020-04-14 OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.7+10) Eclipse OpenJ9 VM AdoptOpenJDK (build openj9-0.20.0, JRE 11 Linux amd64-64-Bit Compressed References 20200416_574 (JIT enabled, AOT enabled) OpenJ9 - 05fa2d361 OMR - d4365f371 JCL - 838028fc9d based on jdk-11.0.7+10)
Producerの作成
まずはProducerを作っていきます。
Spring Initializrを使ってさくっと作っていきます。
選択項目は以下のようにしました。
Project : Maven Project Language : Java Spring Boot : 2.3.0 Packageing: jar Java : 14 Dependencies : Contract Verlifier、Spring Web
pom
ダウンロードしたプロジェクトを解凍して、Pomを見てみます。
すべてをここには載せませんが、ポイント以下の依存と、プラグインです。
依存
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency>
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>2.2.2.RELEASE</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> </configuration> </plugin>
この2つをPomに記述することで、 Spring Cloud Contractの利用ができます。
後ほど説明しますが、pluginのところにはテストのBase Classを設定してやる必要があります。
Contractを記述する
Producerの実装をしていきます。
まずはContractを記述します。前述の通りyamlファイルで記述します。
Contractはデフォルトでは、$rootDir/src/test/resources/contracts
に配置します。
hello-contract.yml
request: method: GET url: /hello matchers: response: status: 200 body: "hello": "world" headers: Content-Type: application/json
上記の契約ではProducerに対し、GETメソッドで/hello
のエンドポイントへアクセスされた際に200
のレスポンスコードとapplication/json
のコンテントタイプで、{"hello": "world"}
のJsonを返却することを定義しています。
これは、単純な例ですが契約にはもっと複雑なことを記述することが可能です。
テストコードの自動生成
Spring Cloud Contractでは作成したContractに従ってテストを自動生成してくれます。
以下のコマンドを叩きプロジェクトをビルドします。
$ ./mvnw clean install
この際、現時点ではビルドは失敗しますが、一旦スルーして、target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests
を見てみると、以下のテストが生成されていることが確認できます。
package org.springframework.cloud.contract.verifier.tests; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; 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") public class ContractVerifierTest { @Test public void validate_hello_contract() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/hello"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).isEqualTo("application/json"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).field("['hello']").isEqualTo("world"); } }
ざっくりコードをみる限り、Contractに基づいたテストを行ってくれていますね。
テストはデフォルトでは、MockMvc
を用いて生成されます。
設定を変えることによってJAX-RS
クライアントや、Web-Fluxのアプリを作成している場合はWebClient
を利用することも可能なようです。
Base Classを設定する
改めて、ビルドが失敗している原因をみてみます。
先程の./mvnw clean install
のログ出力(抜粋)を以下に示します。
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test (default-test) on project contractproducer: There are test failures. [ERROR] [ERROR] Please refer to /path/to/user/project/contractproducer/target/surefire-reports for the individual test results. [ERROR] Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. [ERROR] -> [Help 1]
/target/surefire-reports
配下にテストの結果が出力されているようなので見てみましょう。
org.springframework.cloud.contract.verifier.tests.ContractVerifierTest.txt
------------------------------------------------------------------------------- Test set: org.springframework.cloud.contract.verifier.tests.ContractVerifierTest ------------------------------------------------------------------------------- Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.232 s <<< FAILURE! - in org.springframework.cloud.contract.verifier.tests.ContractVerifierTest validate_hello_contract Time elapsed: 0.23 s <<< ERROR! java.lang.IllegalStateException: You haven't configured a MockMVC instance. You can do this statically RestAssuredMockMvc.mockMvc(..) RestAssuredMockMvc.standaloneSetup(..); RestAssuredMockMvc.webAppContextSetup(..); or using the DSL: given(). mockMvc(..). .. at org.springframework.cloud.contract.verifier.tests.ContractVerifierTest.validate_hello_contract(ContractVerifierTest.java:26)
エラーコードを読む限り、MockMvcの設定がされていなかったので怒られているみたいですね。
Spring Cloud Contractではこのように自動生成されたテストに対する設定をBase Classを用いて設定することができます。
MockMvcの設定を行なうためのクラスを作成します。
ContractTestBase.java
package dev.hirooka.contractproducer; import io.restassured.module.mockmvc.RestAssuredMockMvc; import org.junit.jupiter.api.BeforeEach; public class ContractTestBase { @BeforeEach public void setup() { RestAssuredMockMvc.standaloneSetup(new HelloController()); } }
まだHelloController
は作成していませんが、ポイントはMockMvcの設定をこのクラスで行っています。
次に、作成したContractTestBase
をSpring Cloud Contractのプラグインに認識させてやる必要があります。
pomの以下の箇所を追記します。
pom.xml
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>2.2.2.RELEASE</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <!--ここを追記する--> <baseClassForTests>dev.hirooka.contractproducer.ContractTestBase</baseClassForTests> </configuration> </plugin>
configuration
タグの子タグであるbaseClassForTests
に先ほど作成したBase Classをここに指定します。
これで、設定は完了です。
Producerの実装を行なう
Contractとテストを作成したので、Producerの実装を行います。
先程Bass Classの中で指定していたHelloController
とHello
DTOを実装します。
Hello.java
public class Hello { public Hello(String hello) { this.hello = hello; } private String hello; public String getHello() { return hello; } public void setHello(String hello) { this.hello = hello; } }
HelloController.java
public class HelloController { @GetMapping("/hello") public Hello greeting(){ return new Hello("world"); } }
これでProducerの実装は完了です。
それではテストを実行してみましょう。
$ ./mvn test (略) [INFO] [INFO] Results: [INFO] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 6.562 s [INFO] Finished at: 2020-05-23T15:21:53+09:00 [INFO] ------------------------------------------------------------------------
無事成功しましたね。
一応、テストを落としてみましょう。
例えば、HelloController
のworld
をtypoしてword
と書いてしまった場合にテストを行なうと以下のような結果になります。
(以下抜粋) [ERROR] Errors: [ERROR] ContractVerifierTest.validate_hello_contract:35 » IllegalState Parsed JSON [{"... [INFO] [ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 6.499 s [INFO] Finished at: 2020-05-23T15:32:31+09:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test (default-test) on project contractproducer: There are test failures. [ERROR] [ERROR] Please refer to /path/to/user/project/contractproducer/target/surefire-reports for the individual test results. [ERROR] Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
また例のごとく、結果がtarget/surefire-reports
配下にあるので見て見ます。
dev.hirooka.contractproducer.ContractVerifierTest.txt
------------------------------------------------------------------------------- Test set: dev.hirooka.contractproducer.ContractVerifierTest ------------------------------------------------------------------------------- Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.321 s <<< FAILURE! - in dev.hirooka.contractproducer.ContractVerifierTest validate_hello_contract Time elapsed: 0.32 s <<< ERROR! java.lang.IllegalStateException: Parsed JSON [{"hello":"word"}] doesn't match the JSON path [$[?(@.['hello'] == 'world')]] at dev.hirooka.contractproducer.ContractVerifierTest.validate_hello_contract(ContractVerifierTest.java:35)
きちんとContractによるテストが行われているようですね。
Consumerの実装を行なう
Producerの実装が完了したので、今度はConsumerを実装していきます。
今回はConsumer側の実装もSpringを使って作っていこうと思います。
Spring Initializrで先ほどとほぼ同じプロジェクトを作成しますが、今度はContract Vifier
は依存に追加せずに作成します。
その代わりに、Contract Stub Runner
を依存として追加してやります。
Consumer側のサービス
ConsumerはProducerに通信を行なう以下のServiceを持ちます。
@Service public class ConsumerService { private RestTemplate restTemplate; public ConsumerService(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public Hello getHello(){ ResponseEntity<Hello> forEntity = restTemplate.getForEntity("http://localhost:8081/hello", Hello.class); return forEntity.getBody(); } }
RestTemplateのConfigやHelloクラスの実装は省略します。
Consumer側のテストを記述する
Spring Cloud Contractのcontract-stub-runner
を用いれば、作成したContractをもとにスタブサーバを自動で作成し立ち上げてくれます。
立ち上がるスタブはデフォルトではWireMockが利用されます。
スタブサーバの設定は@AutoConfigureStubRunner
を用いて行います。
ConsumerServiceTes.java
@SpringBootTest @AutoConfigureStubRunner( stubsMode = StubRunnerProperties.StubsMode.LOCAL, ids = "dev.hirooka:contractproducer:+:stubs:8081" ) public class ConsumerServiceTest { @Autowired private ConsumerService consumerService; @Test void consumerTest() { Hello hello = consumerService.getHello(); assertThat(hello.getHello()).isEqualTo("world"); } }
Spring Cloud ContractはMavenリポジトリにデプロイされているContractを探してきて、スタブを作成してくれます。
ids
の部分ではContractを含むプロジェクトのgroup-id
とartifact-id
とそのバージョン、また、スタブのポートを指定します。
stubsMode = StubRunnerProperties.StubsMode.LOCAL
はローカルリポジトリを利用するための設定です。
もちろん、NEXUS等のMavenリポジトリを自分で立てて、リモートリポジトリからContractを探すことも可能です。その場合はLOCAL
ではなく、REMOTE
を利用します。
このテストを実行するとテストが成功して、以下のログが吐かれます。
2020-05-23 22:13:42.524 INFO 20251 --- [p1861338103-248] WireMock : Admin request received: 127.0.0.1 - POST /mappings Connection: [keep-alive] User-Agent: [Apache-HttpClient/4.5.6 (Java/11.0.6)] Host: [localhost:8081] Content-Length: [358] Content-Type: [text/plain; charset=UTF-8] { "id" : "d0f0e53e-5079-4f0b-8f09-c12f19c0380e", "request" : { "url" : "/hello", "method" : "GET" }, "response" : { "status" : 200, "body" : "{\"hello\":\"world\"}", "headers" : { "Content-Type" : "application/json" }, "transformers" : [ "response-template" ] }, "uuid" : "d0f0e53e-5079-4f0b-8f09-c12f19c0380e" }
WireMockに対して、Contractに基づく設定が行われているのがわかりますね。
これで、Consumer側の実装も完了しました。
Contractを変更してみる
例えば、Contractが以下のように変更されたことを想定します。
request: method: GET url: /hello matchers: response: status: 200 body: # Contractの内容が変更 "message": "hello spring contract" headers: Content-Type: application/json
この場合、Contractに基づいてテストされるProducerは落ちることはもちろん、 新しいContractがMavenリポジトリデプロイされ次第、Consumer側で建てられるスタブも自動的に変わりテストが落ちるようになるので、契約が変更されたことが検知できるようになります。
ProducerはContractに基づいた実装が保証されているので、Consumer側は単にスタブを用いたテストを行なうだけでよくなります。
感想
今回はレスポンスボディとして、スタティックな値を返すだけのContractを記述しましたがregrexを利用してもっと複雑な記述が可能です。
また、少し紹介しましたが、JVM言語以外での利用の際のプラクティスなどもあるようなので、そちらも今後試してみたいです。