マイクロサービスアーキテクチャのアプリケーションに対するテストのアプローチとしてCDCというのがあるそうです。日本語にすると消費者主導契約。なんの話だか全くわかりませんが、Spring Cloud Contractを使用して実現できるということなので少し試してみました。
Spring Cloud Contract
こちらですね。
CDCそのものの解説は・・・ちょっと大変なのでこの辺りの説明がわかりやすいかもしれません。

Spring Cloud Contractのリファレンスを読む限りざっくりできることはこんな感じのようです。
マイクロサービスアーキテクチャにおける各アプリケーション(サービス)の中で、
- Provider(呼ばれる側)アプリケーションにおいてConsumerが定めたContract(アプリケーションの仕様定義)を元にProviderアプリケーションのテストコードを生成する。
※Consumerが定めた仕様に従っている事を保証する - Consumer(呼ぶ側)アプリケーションのテストにおいてContractを元にMockサービス(スタブ)を生成し、テストを実行する。
※Contractに準拠したスタブを自動生成してテストを行う
ということで、各アプリケーションでContractを共有してそれを守っている限りにおいて各サービスが認識している仕様がずれたりしないで開発が出来ますね、ということのようです。
この仕組を用いて、
- 全サービスをデプロイしてテストを行う
- ProviderのスタブをConsumerが作成してテストを行う
というよくあるテスト方法のデメリットを解消しよう、というコンセプトの模様。
サンプルアプリケーション
ConsumerとProviderのRESTアプリケーションが一つずつの簡単な構成を例に説明していきます。実物はこちらです。
各アプリケーションはこんな感じになっています。”簡単なサンプル”を思いつくセンスがあまり無いようなので細かいところは多めに見てやってください(^_^;)
Provider
ユーザのID、名前、年齢を管理しているアプリケーション。こんな感じのテーブルを持っています。データベースはH2を使用。
id | BIGINT | NOT NULL, AUTO_INCREMENT,PRIMARY KEY |
name | VARCHAR(255) | NOT NULL |
age | INT | NOT NULL |
Path Variableでidを指定して↑の1行分のJSONを返すGETメソッドを1つ保持。
Consumer
ユーザの操作履歴を保持するアプリケーション。テーブルはこんな感じ。
id | BIGINT | NOT NULL,PRIMARY KEY |
name | VARCHAR(255) | NOT NULL,PRIMARY KEY |
createAt | TIMESTAMP | NOT NULL |
userAction | CHAR(30) | NOT NULL |
idとuserActionを指定してレコードを追加するPOSTメソッドを一つ保持。レコードを追加するときにProviderのGETメソッドを使用してnameを取得する。
Path Variableでidを指定してidに紐づくuserActionをまとめて取得するGETメソッドを一つ保持。
ライブラリ
実装に使用したもののバージョン等はこんな感じです。
- kotlin(1.2.51)
- java(1.8.0_171)
- spring boot(2.0.3.RELEASE)
- spring cloud contract(2.0.0.RELEASE)
- Gradle(4.8.1)
実装手順
Providerの実装
ContractはProviderアプリケーションが保持し、Mavenの(ローカル)リポジトリ上にinstallされたProviderをConsumerが参照するのが基本的なパターンのようなので、先にProviderを実装する必要があります。
build.gradleの設定
buildscript
にspring-cloud-contract-gradle-plugin
を追加
dependencies { // 省略 classpath("org.springframework.cloud:spring-cloud-contract-gradle-plugin:${springCloudContractVersion}") // 省略 } apply plugin: 'spring-cloud-contract'
dependencyManagement
にspring-cloud-contract-dependencies
を追加
apply plugin: 'io.spring.dependency-management' dependencyManagement { imports { mavenBom 'org.springframework.cloud:spring-cloud-contract-dependencies:2.0.0.RELEASE' } }
dependencies
にspring-cloud-starter-contract-verifier
とgroovy-all
を追加
dependencies { // 省略 testCompile('org.codehaus.groovy:groovy-all') testCompile('org.springframework.cloud:spring-cloud-starter-contract-verifier') // 省略 }
リファレンスでの説明はこの辺り。
Contractの作成
ProviderプロジェクトにGroovyまたはYAMLでContractを作成します。デフォルトのパスはsrc/test/resources/contracts
になっています。
配下のディレクトリに制約は無いように見えるので、RestController
ごとにディレクトリを彫り、エンドポイント毎にファイルを作成する感じが良いかも?しれません。その辺はお好みで。
今回作成したContract(groovy)はこちら。
org.springframework.cloud.contract.spec.Contract.make { request { method 'GET' url $(consumer(regex('/user/[1-5]{1}'))) } response { status 200 headers { header('Content-Type', 'application/json;charset=UTF-8') } body( "id": $(producer(regex('[0-9]{1}'))), "name": $(producer(regex('[a-zA-Z]{1,255}'))), "age": $(producer(regex('[0-9]{1,2}'))) ) } }
$(consumer(regex('/user/[1-5]{1}')))
の部分の正規表現は、Providerのテストコードを生成する際にマッチングする範囲でリクエストパスを生成してくれます。
$(producer(regex('[0-9]{1}')))
のところは、レスポンスの定義なので、レスポンスがこの正規表現にマッチするかをアサーションするテストコードが生成されます。
Contractの定義方法はそうそう説明しきれるものではないので公式のリファレンスを参考に組み立ててみてください。
基底テストクラスの作成
ProviderでContractを元に生成されるテストクラスの基底クラスを作成します。
@RunWith(SpringRunner.class) @SpringBootTest(classes = ProviderApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class ContractTestBase { @Autowired UserRestController userRestController; @Before public void setup() { RestAssuredMockMvc.standaloneSetup(userRestController); } @Test public void contextLoads() { } }
RestAssuredMockMvc
で設定する対象のController
をテスト用のコンテキストから取るのが良いかと思い@SpringBootTest
を使いました。もしかしてバッドなやり方ですかねぇ・・・どうなんだろう。
併せて、作成した基底クラスをbuild.gradleに設定します。
contracts { baseClassForTests = 'com.example.provider.ContractTestBase' }
テストの実行
ここまで設定しておくと、Providerのビルド時にテストコードが生成され、実行されます。テストコードの生成を行うgradleタスクはgenerateContractTests
になります。
生成されたテストコードはこちら。
public class UserRestControllerTest extends ContractTestBase { @Test public void validate_getUserById() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/user/3"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).field("['id']").matches("[0-9]{1}"); assertThatJson(parsedJson).field("['name']").matches("[a-zA-Z]{1,255}"); assertThatJson(parsedJson).field("['age']").matches("[0-9]{1,2}"); } }
リクエストはPath Variableの部分がランダムに設定されたパスが生成されています。また、レスポンスのアサーションについてはContractに設定した部分が検証されています。Contractの書き方はちゃんとやろうとすると難しそうですね。
Consumerの実装
続いてConsumer側の実装です。Consumer側ではスタブの自動生成の設定を行います。
build.gradleの設定
spring-cloud-contract-gradle-plugin
の追加、spring-cloud-contract-dependencies
の追加はProviderと同様です。dependencies
にはspring-cloud-starter-contract-stub-runner
を追加します。
dependencies { // 省略 testCompile('org.springframework.cloud:spring-cloud-starter-contract-stub-runner') // 省略 }
テストコードの作成
ConsumerのテストコードはRest-Assured等を使用してJUnitコードしますが、そのコードに対してスタブ生成の記述を行います。
@RunWith(SpringRunner::class) @SpringBootTest(classes = [ConsumerApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureStubRunner(ids = ["com.example:provider:+:stubs:8081"], stubsMode = StubRunnerProperties.StubsMode.LOCAL) class ConsumerApplicationTests { // 省略 @Test fun postUserAction01() { // 省略 } }
@AutoConfigureStubRunner
を使用してスタブ生成の対象を指定します。ids
属性にはスタブを生成するアプリケーションのgroup id,artifact id,version,port番号を配列で指定します。バージョンに+
を指定するとリポジトリ上の最新バージョンを取得します。スナップショットを避けるオプションも用意されているようです。
stubsMode
には参照先リポジトリの指定を行います。上記の例ではLOCAL
(ローカルリポジトリ)を指定していますが、クラスパスおよびリモートリポジトリも指定可能です。
この設定を行うことで、Providerが保持しているContractを元にスタブを生成し、テストにおけるConsumerからのアクセスはスタブへ向けて行われることになります。
なお、テストを実行してスタブを生成する際にはこのようなログが出ます。
2018-07-13 20:39:57.272 INFO 6096 --- [ main] o.s.c.c.s.AetherStubDownloaderBuilder : Will download stubs and contracts via Aether 2018-07-13 20:39:57.275 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Remote repos not passed but the switch to work offline was set. Stubs will be used from your local Maven repository. 2018-07-13 20:39:57.390 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Desired version is [+] - will try to resolve the latest version 2018-07-13 20:39:57.404 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved version is [0.0.1-SNAPSHOT] 2018-07-13 20:39:57.408 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved artifact [com.example:provider:jar:stubs:0.0.1-SNAPSHOT] to C:\Users\User\.m2\repository\com\example\provider\0.0.1-SNAPSHOT\provider-0.0.1-SNAPSHOT-stubs.jar 2018-07-13 20:39:57.409 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacking stub from JAR [URI: file:/C:/Users/User/.m2/repository/com/example/provider/0.0.1-SNAPSHOT/provider-0.0.1-SNAPSHOT-stubs.jar] 2018-07-13 20:39:57.425 INFO 6096 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacked file to [C:\Users\User\AppData\Local\Temp\contracts3048922511173535796] 2018-07-13 20:39:58.252 INFO 6096 --- [ main] wiremock.org.eclipse.jetty.util.log : Logging initialized @5518ms 2018-07-13 20:39:58.315 INFO 6096 --- [ main] w.org.eclipse.jetty.server.Server : jetty-9.2.z-SNAPSHOT 2018-07-13 20:39:58.328 INFO 6096 --- [ main] w.o.e.j.server.handler.ContextHandler : Started w.o.e.j.s.ServletContextHandler@7910f355{/__admin,null,AVAILABLE} 2018-07-13 20:39:58.328 INFO 6096 --- [ main] w.o.e.j.server.handler.ContextHandler : Started w.o.e.j.s.ServletContextHandler@12209826{/,null,AVAILABLE} 2018-07-13 20:39:58.338 INFO 6096 --- [ main] w.o.e.j.s.NetworkTrafficServerConnector : Started NetworkTrafficServerConnector@674184d{HTTP/1.1}{0.0.0.0:8081} 2018-07-13 20:39:58.338 INFO 6096 --- [ main] w.org.eclipse.jetty.server.Server : Started @5605ms 2018-07-13 20:39:58.339 INFO 6096 --- [ main] o.s.c.contract.stubrunner.StubServer : Started stub server for project [com.example:provider:0.0.1-SNAPSHOT:stubs] on port 8081 2018-07-13 20:39:58.624 INFO 6096 --- [tp1478683866-29] /__admin : RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.AdminRequestHandler. Normalized mapped under returned 'null' 2018-07-13 20:39:58.650 INFO 6096 --- [tp1478683866-29] WireMock : Received request to /mappings with body { "id" : "cb5e8674-38d3-43e2-bf0c-c0f7e9b8e37b", "request" : { "url" : "/ping", "method" : "GET" }, "response" : { "status" : 200, "body" : "OK" }, "uuid" : "cb5e8674-38d3-43e2-bf0c-c0f7e9b8e37b" } 2018-07-13 20:39:58.771 INFO 6096 --- [tp1478683866-28] WireMock : Received request to /mappings with body { "id" : "22da700b-0dd5-49ba-a3c7-ec0f36d17475", "request" : { "url" : "/health", "method" : "GET" }, "response" : { "status" : 200, "body" : "OK" }, "uuid" : "22da700b-0dd5-49ba-a3c7-ec0f36d17475" } 2018-07-13 20:39:58.836 INFO 6096 --- [tp1478683866-32] WireMock : Received request to /mappings with body { "id" : "5641c7d6-dc2c-456b-968c-3f6b18b3c2f9", "request" : { "urlPattern" : "/user/[1-5]{1}", "method" : "GET" }, "response" : { "status" : 200, "body" : "{\"name\":\"jR\",\"id\":\"3\",\"age\":\"82\"}", "headers" : { "Content-Type" : "application/json;charset=UTF-8" }, "transformers" : [ "response-template" ] }, "uuid" : "5641c7d6-dc2c-456b-968c-3f6b18b3c2f9" } 2018-07-13 20:39:58.850 INFO 6096 --- [ main] o.s.c.c.stubrunner.StubRunnerExecutor : All stubs are now running RunningStubs [namesAndPorts={com.example:provider:0.0.1-SNAPSHOT:stubs=8081}]
リポジトリからProviderをダウンロードしてスタブを生成しているのが見て取れます。
まとめ
Spring Cloud Contractを使用したテストの実装方法について解説してみました。他にもフレームワークがあるような(Pact)ことも見かけましたが、Java系だとSpring Bootが流行っている事もあってSpring Cloud Contractが有力なんでしょうか?世の中の動向は正確にはわかりませんが。。。
ProviderがContractに違反するような改修をするとテストが落ちるからそこは守られる、ConsumerはContractを信じてテストしてるから両者の齟齬は発生しない、ということなのでしょうけれど、Contractをちゃんと作るっていうのがなんか難しい感じがします。実際どうなんでしょうねぇ。
さて、今回説明した内容は当然ごく一部で、私もリファレンスを見ながらちょこっと試してみた程度ですので、詳しいことは公式のリファレンスを見てくださいということにはなるのですが何かのお役に立てば幸いです。
追記(2018/07/18)
Springの公式ブログでちょこっとご紹介いただきました。
- This Japanese-language post on Spring Cloud Contract looks (as far as I can tell, after running it through Google Translate) compelling
とのこと。ありがとうございます。
Springを学ぶなら
この辺の本がおすすめ。
この記事に対するコメント