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()) } }
Stubの生成とMavenローカルリポジトリへのインストール
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に従っていることが検証できました。