Consumer Driven Contract TestingをSpring Bootで動かして試してみました。
目次
What is Consumer Driven Contract(CDC)
Consumer Driven Contract Testingとは、Consumer(Clinet)が依存するProvider(API)に対して、期待する挙動をContractとして共有し、Provider側では、Consumer側で定義されたContractに準拠する事を担保するテスト手法になります。
Consumerは、Contractを元にしたモックサーバーを用いてテストを実施し、Provider側ではContractに準拠している事をテストします。
メリット
サービス間連携においての破壊的変更の検知が、その他のテスト内で行うのと比べてやりやすいです。
例えばマイクロサービスなど、特定のアプリケーションや機能を実現するのに、複数のサービスが関わる状況においては、Clentが依存する外部APIが複数存在、もしくはその逆で自チームが管理するAPIに依存するClientが複数存在する状況があると思います。
その中で自分たちが行う変更がClientに影響が出ないか、また依存APIがそのような変更を行っていないか検知する仕組みとして、Consumer Driven Contractは便利です。
他のテストのレイヤーと比べてみると
Unitテストでは、基本的にProviderの最新の変更に追随することは難しいです。
APIをモックしてテストを実行することになりますが、そのモックは完全にConsumer内で完結してしまっています。
例えば、API側の変更に応じて、Client側のUnitテストで利用するスタブデータを更新する事も出来ますがClient側のUnitテストを実行しなければAPIの変更が破壊的変更であることは検知できません。
マイクロサービスとして各サービスが独立してデプロイを行いたい環境で、Providerのリリース時にClient側のテストを実行するのは本末転倒感があります。
e2eテストに関しては、厚めに用意すればほぼ検知できるとは思います。
しかし、e2eテストは、Unitテスト等と比べて実行コストの高いテストです。
一つのマイクロサービスをテストしたいのに、全てのマイクロサービスを用意する必要があり、実際に通信を行うのでUnitテスト等と比べると実行時間もかかります。
結果としてフィードバックを得られるのが遅くなってしまうのがネックです。
各システム単位でのIntegrationテストを実際にステージング環境等に対して繋いで行っても、Unitテストと同じく、Consumer側のテストを実行しないことには気づくことが出来ません。
Client側でどう使われているかを徹底的に再現した上で、Provider側のテストを行なえば、Provider側で破壊的変更を検知できる可能性はありますが、Provider側で複数のClientのユースケースを把握しておくのは非常に辛い気がします。
またProviderの破壊的変更に関してはConsumerの方が関心が高いので、Consumer側からProviderに守ってほしいルールを定められる方がより効果的なはずです。
このように、今回挙げたテストのレイヤーでは破壊的変更を検知するのにはなかなか手間ですが、上記の課題に対して、Consumer Driven Contract Testingは非常に有効な対策になり得ます。
What is Pact
pactはConsumer Driven Contract Testingを実現するためのツールの一つになります。
複数の言語での実装が存在しますが、今回はpact-jvmを用いてSpring Bootで実装しているAPI間でのConsumer Driven Contract Testingをどのように実現するのか試してみます。
Contractを共有する仕組み
Contractを共有する仕組みとして、pact-brokerというOSSを用いることが出来ます。
これを使う事で、Consumer側でContractが生成された際に、pact-brokerに対してファイルをアップロードし、
Providerではpact-broker経由で自身の関連するContractファイルを引っ張ってくる事で、Consumer-Provider間でContractを共有することができます。
docker-imageも提供されています。
サンプルコード
コードは以下リポジトリに置いています。
https://github.com/r-kgy/spring-pact-demo
ある程度は実装されているAPIの方が理解しやすいかなと思いましたが、pactを動かしてみるのが主題なので今回はシンプルな実装で試してみます。
サンプルで利用するAPIでは、8082番ポートに立っているBE API想定のAPIに対して、8081番ポートで立っているFE APIから各エンドポイントを叩きます。
ひとまずpactを動かしたいので、実装内容に関してはあまり意味はありません。
http://localhost:8081/ -> http://localhost:8082/books
http://localhost:8081/1 -> http://localhost:8082/book/{bookId}
Consumer
最初にConsumer側の実装です。
まずはgradleファイルにpact-jvm-consumerをdependenciesとして追加します。
testImplementation("au.com.dius:pact-jvm-consumer-junit5:4.0.1")
次に同じくbuild.gradle.ktsのtasksに、生成されるContractファイルが吐き出されるディレクトリを指定します。
tasks.withType<Test> {
useJUnitPlatform()
systemProperties["pact.rootDir"] = "$buildDir/pacts"
}
続いて、テストコードを書いていきます。
assertionのJSON周りは少しサボっていますが、本質的な箇所ではないと思うので、とりあえずこのまま進めます。
@PactTestFor(providerName = "BookProvider")
で、Contractを共有するProviderの名前を指定します。
@Pact(consumer = "BookConsumer")
のアノテーションが存在するテストメソッドの中では、どのパスにどのようなリクエストを送った際に、どのようなレスポンスが返される事を期待するかを記述します。
@ExtendWith(PactConsumerTestExt::class)
@PactTestFor(providerName = "BookProvider")
class PactBookConsumerTest {
private val headers = MapUtils.putAll(HashMap<String, String>(), arrayOf("Content-Type", "application/json"))
@Pact(consumer = "BookConsumer")
fun books(builder: PactDslWithProvider): RequestResponsePact {
return builder
.given("Books exist")
.uponReceiving("all books data")
.path("/books")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body("[{\"id\":1,\"name\":\"sample book a\",\"price\":{\"taxExclude\":1000,\"taxInclude\":1100}},{\"id\":2,\"name\":\"sample book b\",\"price\":{\"taxExclude\":500,\"taxInclude\":550}}]")
.toPact()
}
@Test
@PactTestFor(pactMethod = "books")
internal fun testBooks(mockServer: MockServer) {
val httpResponse = Request.Get(mockServer.getUrl() + "/books").execute().returnResponse()
assertThat(httpResponse.statusLine.statusCode, `is`(equalTo(200)))
assertThat(httpResponse.entity.content.bufferedReader().use(BufferedReader::readText),
`is`(equalTo(
"[{\"id\":1,\"name\":\"sample book a\",\"price\":{\"taxExclude\":1000,\"taxInclude\":1100}},{\"id\":2,\"name\":\"sample book b\",\"price\":{\"taxExclude\":500,\"taxInclude\":550}}]"
)))
}
@Pact(consumer = "BookConsumer")
fun book(builder: PactDslWithProvider): RequestResponsePact {
return builder
.given("Book exist")
.uponReceiving("book data")
.path("/book/1")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body(
PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "sample book a")
.`object`("price")
.integerType("taxExclude", 1000)
.integerType("taxInclude", 1100)
.closeObject()
.close()
)
.toPact()
}
@Test
@PactTestFor(pactMethod = "book")
internal fun testBook(mockServer: MockServer) {
val httpResponse = Request.Get(mockServer.getUrl() + "/book/1").execute().returnResponse()
assertThat(httpResponse.statusLine.statusCode, `is`(equalTo(200)))
assertThat(httpResponse.entity.content.bufferedReader().use(BufferedReader::readText),
`is`(equalTo(
"{\"price\":{\"taxExclude\":1000,\"taxInclude\":1100},\"name\":\"sample book a\",\"id\":1}"
)))
}
}
ひとまずここまででテストを実行して通過すると、先ほど指定してディレクトリ配下にContractファイルが生成されているのを確認できるかと思います。
{
"provider": {
"name": "BookProvider"
},
"consumer": {
"name": "BookConsumer"
},
"interactions": [
{
"description": "book data",
"request": {
"method": "GET",
"path": "/book/1"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"price": {
"taxExclude": 1000,
"taxInclude": 1100
},
"name": "sample book a",
"id": 1
},
"matchingRules": {
"body": {
"$.id": {
"matchers": [
{
"match": "integer"
}
],
"combine": "AND"
},
"$.name": {
"matchers": [
{
"match": "type"
}
],
"combine": "AND"
},
"$.price.taxExclude": {
"matchers": [
{
"match": "integer"
}
],
"combine": "AND"
},
"$.price.taxInclude": {
"matchers": [
{
"match": "integer"
}
],
"combine": "AND"
}
}
}
},
"providerStates": [
{
"name": "Book exist"
}
]
},
{
"description": "all books data",
"request": {
"method": "GET",
"path": "/books"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": [
{
"id": 1,
"name": "sample book a",
"price": {
"taxExclude": 1000,
"taxInclude": 1100
}
},
{
"id": 2,
"name": "sample book b",
"price": {
"taxExclude": 500,
"taxInclude": 550
}
}
]
},
"providerStates": [
{
"name": "Books exist"
}
]
}
],
"metadata": {
"pactSpecification": {
"version": "3.0.0"
},
"pact-jvm": {
"version": "4.0.1"
}
}
}
Provider
次にProvider側の実装です。
最初にbuild.gradle.ktsにdependenciesを追加します。
testImplementation("au.com.dius:pact-jvm-provider-junit5_2.12:3.5.20")
テストコードは以下の通りです。
@Provider("BookProvider")
でConsumer側で指定したProvider名と一致させます。
また@PactFolder("../pact-demo-consumer/build/pacts")
ではConsumer側で生成したContractファイルが存在しているディレクトリを記述します。
@State("Books exist")
はConsumerのテストで.given("Books exist")
で指定されたいた内容を記述します。
@ExtendWith(SpringExtension::class)
@Provider("BookProvider")
@PactFolder("../pact-demo-consumer/build/pacts")
class BookProviderTest {
@BeforeEach
internal fun setupTestTarget(context: PactVerificationContext) {
context.target = HttpTestTarget("localhost", 8082, "/")
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider::class)
internal fun pactVerificationTestTemplate(context: PactVerificationContext) {
context.verifyInteraction()
}
@State("Books exist")
fun booksExist() {}
@State("Book exist")
fun bookExist() {}
}
Providerをローカルで立ち上げた状態で、テストを実行して通過することを確認します。
BE側の実装を変更し、Contractファイルに添わないレスポンスを返すとテストが落ちるだけでなく、
@State
アノテーションが付与されているメソッドをコメントアウトなどして、.given()
で与えられている内容が全て実行されない場合もテスト通過しない事を確認できるかと思います。
このようにContractで期待したリクエスト/レスポンスに従えていな場合だけでなく、Contractで定義されたテスト全てに対応する事も強制されるようになっています。
終わりに
Consumer Driven Contarct(CDC)の概要や、CDCを実装するためのpactをspring boot環境で動かしてみました。
次回はpact-brokerを組み込んだ上で、実装を行ってみたいと思います。
参考
https://martinfowler.com/articles/consumerDrivenContracts.html https://techlife.cookpad.com/entry/2016/06/28/164247