spring-cloud-contractを使ってみた

# 概要

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361020/3ad54d40-a66d-6e95-4261-5626823974ad.png)

# 先に所感

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

# 参考

- 公式：https://spring.io/projects/spring-cloud-contract
- Youtube: https://www.youtube.com/watch?v=sAAklvxmPmk




# Producer側の実装

## アプリケーション

```Java: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作成
- https://start.spring.io/

```groovy: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側のテスト時にこれを元にテストが作成される

```groovy: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')
        }
    }
}
```

## テスト準備

```Java: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側の実装

## アプリケーション

```Java: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
```groovy: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したライブラリを指定

```Java: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側を自動テストするにはどうしたら良いんだろう…