3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

spring-cloud-contractを使ってみた

Last updated at Posted at 2020-01-25

概要

  • アプリケーションの改修時に他のアプリケーションへの影響を聞き取り調査をしていることがあるが、システム的に気づける仕組みを作れないかと思っていた時にspring-cloud-contractを見つけたので触ってみた
  • gradle使用
  • 年齢を受け取ってadult or childを返すアプリケーションでやってみる!
  • ざっくりとした構成は以下の通り

image.png

先に所感

  • producer側のAPIのテストコードを自動生成してくれるから楽
  • consumer側の煩雑なスタブ実装がいらなくなるから幸せ!
  • 何よりproducer,consumerで期待値を共有できるから安心!!
  • あとgroovyが書きやすい

参考

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で場所を指定
  • 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側を自動テストするにはどうしたら良いんだろう…
3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?