Spring Cloud Contractとは
Spring Cloud Contractは、Consumer-Driven Contract testing1を実現するためのフレームワークです。
お試し構成
Producerをserver-serviceとして、Consumerをclient-serviceとして構築しています。
Producer(server-service)
build.gradle
dependenciesにspring-cloud-starter-contract-verifier
をpluginにorg.springframework.cloud.contract
を追加します。これで、契約を元にしたテストコードの生成と実行、Stubの生成などが行われるようになります。
設定を変更したい場合には、contracts
に対してオプションを指定します。指定できるオプションは、Spring Cloud Contract: Configuration Optionsにまとまっています。
baseClassForTests
については、生成されるテストコードの親クラスを指定します。
import org.springframework.cloud.contract.verifier.config.TestFramework
plugins {
id "org.springframework.cloud.contract" version scContractVersion
}
apply plugin: 'maven'
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier")
}
contracts {
testFramework = TestFramework.JUNIT5
basePackageForTests = 'siosio'
baseClassForTests = 'siosio.BaseTestClass'
}
アプリケーション側の実装
GET sample/{id}
で受け取ったid
にname
を追加したjsonを返すだけのサーバサイドにしています。
package siosio
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.*
@SpringBootApplication
class ServerSideApplication
fun main(args: Array<String>) {
runApplication<ServerSideApplication>(*args)
}
@RestController
@RequestMapping("/sample")
class SampleController(
private val sampleService: SampleService
) {
@GetMapping("{id:\\d+}")
fun get(@PathVariable id: Int): SampleResponse {
return sampleService.get(id)
}
}
@Service
class SampleService {
fun get(id: Int): SampleResponse {
return SampleResponse(id, "name")
}
}
data class SampleResponse(
val id: Int,
val name: String,
)
@ResponseStatus(HttpStatus.NOT_FOUND)
class NotFoundException : RuntimeException()
テストコードの親クラス
build.gradleで指定した親クラスの実装になります。
検証対象のエンドポイントを持つControllerがどれかを、RestAssuredMockMvc.standaloneSetup
に設定します。
Controller
が依存しているService
をモック化したい場合には、このクラスでモックを生成して設定してあげることができます。
abstract class BaseTestClass {
private val mockService: SampleService = mockk()
@BeforeEach
open fun setup() {
val slot = slot<Int>()
every { mockService.get(capture(slot)) } answers {
if (slot.captured == 999) throw NotFoundException()
SampleResponse(slot.captured, "name_${slot.captured}")
}
RestAssuredMockMvc.standaloneSetup(SampleController(mockService))
}
}
Contractの定義
Contractはデフォルト設定では、src/t/est/resources/contracts
に定義します。今回は、正常系と404を返すケースのContractを定義してみます。
Contractの定義方法は、Contract DSLを見るとだいたいわかります。
正常系(src/t/est/resources/contracts/GetSample.groovy)
SampleControllerで定義しているエンドポイントを呼び出すrequest
を定義しています。
response
では、request
で送ったid
とname
が戻ってくることを定義します。
import org.springframework.cloud.contract.spec.Contract
Contract.make {
priority 2
request {
method('GET')
url "/sample/${regex('\\d+')}"
}
response {
status(OK())
headers {
contentType(applicationJson())
}
body(
id: fromRequest().path(1),
name: "name_${fromRequest().path(1)}"
)
}
}
404系(src/t/est/resources/contractsGetSample_404.groovy)
期待するresponse
として404を定義します。priority
を正常系より高くして404を優先的に扱えるようにしてあげます。
import org.springframework.cloud.contract.spec.Contract
Contract.make {
priority 1
request {
method('GET')
url ("/sample/999")
}
response {
status(NOT_FOUND())
}
}
buildとインストールタスクを実行してStubの生成とMavenローカルリポジトリへのインストールを行います。
なお、Producer側のContractに従っているかの検証だけであれば、./gradlew test
だけでOKです。
./gradlew build install
テストコードが生成され、Contractに従っているかの検証が行われます。
今回は、正常系と404系の2ケースともContractに従っていることがわかります。
Consumer(client-service)
build.gradle
dependenciesにspring-cloud-starter-contract-stub-runner
を追加します。これで、Producer側で生成したStubを使用したテストコードがかけるようになります。
dependencies {
testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner")
}
Producer側を呼び出すコード
RestTemplate
を使ってエンドポイントを叩くだけの実装です。実際は、urlは設定ファイルに切り出したりとかが必要になると思いますが…
@Service
class SampleService(
private val restTemplate: RestTemplate
) {
fun get(id: Int): SampleApiResponse {
return restTemplate.getForEntity("http://localhost:8080/sample/{id}", SampleApiResponse::class.java, id).body!!
}
}
data class SampleApiResponse(
val id: Int,
val name: String,
)
テストコード
Producer側と違い、テストコードを作成する必要があります。
AutoConfigureStubRunner
アノテーションを使ってどのStubを使うのかを指定します。
id
アノテーションにstubの情報を指定します。大事なのは、最後に指定するポート(8080の部分)は実際にProducerを呼び出す際に指定しているポートを指定します。
Mavenのローカルリポジトリを使っているので、stubsMode
にはLOCAL
を指定します。
リモートリポジトリを使う場合には、REMOTE
を設定しrepositoryRoot
を設定する必要があるようです。
package siosio
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.test.context.TestConstructor
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.web.client.HttpClientErrorException
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureStubRunner(ids = ["siosio:server-service:1.0.0:stubs:8080"], stubsMode = StubRunnerProperties.StubsMode.LOCAL)
internal class SampleServiceTest(
private val sut: SampleService
) {
@Test
internal fun 正常系() {
val actual = sut.get(100)
Assertions.assertThat(actual)
.isEqualTo(SampleApiResponse(100, "name_100"))
}
@Test
internal fun `404系`() {
Assertions.assertThatThrownBy { sut.get(999) }
.isInstanceOf(HttpClientErrorException.NotFound::class.java)
}
}
テスト結果
テスト成功ですね。これでConsumer側もContractに従っていることが検証できました。
github.com