概要
- アプリケーションの改修時に他のアプリケーションへの影響を聞き取り調査をしていることがあるが、システム的に気づける仕組みを作れないかと思っていた時にspring-cloud-contractを見つけたので触ってみた
- gradle使用
- 年齢を受け取って
adult
orchild
を返すアプリケーションでやってみる! - ざっくりとした構成は以下の通り
先に所感
- producer側のAPIのテストコードを自動生成してくれるから楽
- consumer側の煩雑なスタブ実装がいらなくなるから幸せ!
- 何よりproducer,consumerで期待値を共有できるから安心!!
- あとgroovyが書きやすい
参考
- 公式:https://spring.io/projects/spring-cloud-contract
- Youtube: https://www.youtube.com/watch?v=sAAklvxmPmk
Producer側の実装
アプリケーション
ProducerController
import lombok.Data;
import lombok.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
public class ProducerController {
// curl localhost:8081/checkAge -X POST -H 'Content-Type: application/json' -d '{"age":20}'
@PostMapping("/checkAge")
public Mono<Result> checkAge(@RequestBody Person person) {
return Mono.just(person)
.map(Person::getAge)
.map(v -> v >= 20 ? "adult" : "child")
.map(Result::new);
}
@Value
public static class Result {
private String result;
}
@Data
public static class Person {
private int age;
}
}
build.gradle作成
build.gradle
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.springframework.cloud:spring-cloud-contract-gradle-plugin:2.2.1.RELEASE'
}
}
plugins {
id 'org.springframework.boot' version '2.2.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
id 'maven' // localのrepositoryにjarを登録するために使用
}
apply plugin: 'spring-cloud-contract'
group = 'rhirabay'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 11
targetCompatibility = 11
repositories {
mavenCentral()
mavenLocal()
}
ext {
set('springCloudVersion', "Hoxton.SR1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
testImplementation 'io.rest-assured:spring-web-test-client'
testCompileOnly('org.projectlombok:lombok')
testAnnotationProcessor('org.projectlombok:lombok')
testRuntime('org.junit.jupiter:junit-jupiter-engine')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
contracts {
testFramework = org.springframework.cloud.contract.verifier.config.TestFramework.JUNIT5
testMode = 'WEBTESTCLIENT' // webfluxを使用している場合はこれ
baseClassForTests = 'rhirabay.contract.ContractTestBase' // contractテスト時の抽象クラスを指定する
}
test {
useJUnitPlatform()
}
Contractファイルを作成
-
test/resources/contracts
配下に作成 - 「こんなリクエストに対してこんなレスポンスをお約束しますよ」を記述するファイル
- consumer側のテスト時にこれを元にスタブが作成される
- producer側のテスト時にこれを元にテストが作成される
shouldAdult.groovy
package contracts
org.springframework.cloud.contract.spec.Contract.make {
request {
description("age is over 20, so expected 'adult'")
method 'POST'
url '/checkAge'
body ([
age: 20
])
headers {
contentType('application/json')
}
}
response {
status 200
body ([
result: 'adult'
])
headers {
contentType('application/json')
}
}
}
テスト準備
ContractTestBase
import io.restassured.module.webtestclient.RestAssuredWebTestClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import rhirabay.ProducerController;
@ExtendWith(SpringExtension.class)
@WebFluxTest
public abstract class ContractTestBase {
@Autowired
private WebTestClient webTestClient;
@BeforeEach
public void setup() {
RestAssuredWebTestClient.webTestClient(webTestClient);
RestAssuredWebTestClient.standaloneSetup(new ProducerController());
}
}
テスト実行
-
test
タスクを実行! - テストが通ったら
install
タスクでローカルにstubのjarファイルを保存- 先に
bootJar
をしておかないとコケるかも - jarファイルを確認したければ
build/libs
配下に生成されているはず - このjarをconsumer側で読み込むと、先ほど作成したContractファイルを元にスタブが立ち上がる
- 先に
Consumer側の実装
アプリケーション
ConsumerController
import lombok.Data;
import lombok.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@RestController
public class ConsumerController {
private WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8081")
.build();
// curl localhost:8080/checkAge?age=20
@GetMapping("/checkAge")
public Mono<Result> checkAgeLight(@RequestParam int age) {
return webClient.post()
.uri("/checkAge")
.contentType(MediaType.APPLICATION_JSON)
.body(Mono.just(new Person(age)), Person.class)
.retrieve()
.bodyToMono(Result.class);
}
@Data
public static class Result {
private String result;
}
@Value
public static class Person {
private int age;
}
}
build.gradle
build.gradle
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.springframework.cloud:spring-cloud-contract-gradle-plugin:2.2.1.RELEASE'
}
}
plugins {
id 'org.springframework.boot' version '2.2.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
id 'maven'
}
group = 'rhirabay'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 11
targetCompatibility = 11
repositories {
mavenCentral()
mavenLocal()
}
ext {
set('springCloudVersion', "Hoxton.SR1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
testImplementation 'io.rest-assured:spring-web-test-client'
testCompileOnly('org.projectlombok:lombok')
testAnnotationProcessor('org.projectlombok:lombok')
testRuntime('org.junit.jupiter:junit-jupiter-engine')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
test {
useJUnitPlatform()
}
テストを作成
-
stubsMode
はLOCALを指定- 業務で使用するときはNexasとかArtifactoryにあげて
repositoryRoot
で場所を指定
- 業務で使用するときはNexasとかArtifactoryにあげて
- idsで先ほどローカルにinstallしたライブラリを指定
ConsumerControllerTest
@ExtendWith(SpringExtension.class)
@WebFluxTest
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = "rhirabay:springcloudcontract-producer:+:stubs:8081"
)
public class ConsumerControllerTest {
@Autowired
private WebTestClient webClient;
@Test
public void test_adult() {
webClient.get()
.uri("/checkAge?age={age}", 20)
.exchange()
.expectStatus().isOk()
.expectBody(Result.class).isEqualTo(new Result("adult"));
}
@Test
public void test_child() {
webClient.get()
.uri("/checkAge?age={age}", 19)
.exchange()
.expectStatus().isOk()
.expectBody(Result.class).isEqualTo(new Result("child"));
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Result {
private String result;
}
}
テスト実行!
-
test
タスクを実行!
課題?
- producer側を変更した時にconsumer側を自動テストするにはどうしたら良いんだろう…