Spring Cloud ContractでCDCする

はじめに

マイクロサービスで、E2Eテストを書く際にマイクロサービスが依存するマイクロサービスを立ち上げるのが面倒だったり、時には現実的では無い場合があるなぁと最近感じてます。
そんな中でConsumer Driven Contract(CDC)という考え方を耳にはさみ、調べてみるとSpring Cloud Contractと言うプロジェクトでCDCをサポートしているということを知り、その機能を試して見ようと思います。
このブログでは以下のことを目指します。

  • CDCの概要を学ぶ
  • Spring Cloud Contractの基本的な機能を利用してCDCしてみる

Consumer Driven Contract(CDC)とは?

マイクロサービスアーキテクチャにおいて、マイクロサービスAのE2Eテストしたいと考えた際に、マイクロサービスAがマイクロサービスBに依存していて、更にそのマイクロサービスAがマイクロサービスC、D、Eに依存していると仮定します。
その際に、テストしたいのはマイクロサービスAなのにC、D、Eまで立てる必要があります。
この例は単純ですが、例えば1000個からなるマイクロサービスを考えた場合に依存するマイクロサービスすべてを立ててE2Eテストを行なうことは時には現実的では無い場合があると思います。
CDCではそのような課題を解決します。

CDCではConsumer(クライアント)とProducer(サーバ)の間でContract(契約)を結び、Producerでそれぞれその契約に従っているかどうかのテストを行います。
Producerが契約に従っているという原則のもとProducerをスタブにしてしまっても良いというのがCDCのアイディアです。

自分の理解ですが、この契約は一般的にはWebのインターフェースとなるケースが多いと理解しています。

Spring Cloud Contract

Spring Cloud ContractはCDCのアプローチに対してサポートを行っているSpring Cloud傘下のプロジェクトです。 Spring Cloud Contractでは以下のことを目指しています。

  • ATDD(acceptance test-driven developement)とマイクロサービスアーキテクチャをプロモーションする
  • Contractの変更を即座に公開できる方法を提供する
  • サーバサイドのテストコードで、ボイラープレートになる部分を自動生成する
  • HTTPやメッセージングのスタブを自動生成し、Producerが返す値と必ず一致していることを確認する

Spring Cloud Contractは使ってJVM言語でのCDCを可能とします。
多言語のでの利用する際のプラクティスもあるようですが(Spring Cloud Contract in a polyglot world)、今回は一旦触れないで起きます。

Contractとは何か?

Consumerのサービスとして、Producerに何を期待するかを記述し、Producerが受け入れたものがCDCにおけるContractとなります。
Spring Cloud Contractではyamlか、groovyでContractを記述することが可能です。
このブログではyamlでの契約の記述をしてみようと思います。

使ってみる

Spring Bootでプロジェクトを作成します。
また、言語はJavaで書きます。
プロジェクトはConsumerとProducerの2つを作ります。

環境

動作環境は以下の通り

$ uname -srvmpio
Linux 5.3.0-53-generic #47~18.04.1-Ubuntu SMP Thu May 7 13:10:50 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-9.20170808ubuntu1-noarch:security-9.20170808ubuntu1-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.4 LTS
Release:    18.04
Codename:   bionic


$ java -version
openjdk version "11.0.7" 2020-04-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.7+10)
Eclipse OpenJ9 VM AdoptOpenJDK (build openj9-0.20.0, JRE 11 Linux amd64-64-Bit Compressed References 20200416_574 (JIT enabled, AOT enabled)
OpenJ9   - 05fa2d361
OMR      - d4365f371
JCL      - 838028fc9d based on jdk-11.0.7+10) 

Producerの作成

まずはProducerを作っていきます。
Spring Initializrを使ってさくっと作っていきます。
選択項目は以下のようにしました。

Project : Maven Project Language : Java Spring Boot : 2.3.0 Packageing: jar Java : 14 Dependencies : Contract Verlifier、Spring Web

pom

ダウンロードしたプロジェクトを解凍して、Pomを見てみます。
すべてをここには載せませんが、ポイント以下の依存と、プラグインです。

依存

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-verifier</artifactId>
    <scope>test</scope>
</dependency>

プラグイン

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.2.2.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
    </configuration>
</plugin>

この2つをPomに記述することで、 Spring Cloud Contractの利用ができます。
後ほど説明しますが、pluginのところにはテストのBase Classを設定してやる必要があります。

Contractを記述する

Producerの実装をしていきます。
まずはContractを記述します。前述の通りyamlファイルで記述します。
Contractはデフォルトでは、$rootDir/src/test/resources/contractsに配置します。

hello-contract.yml

request:
  method: GET
  url: /hello
  matchers:
response:
  status: 200
  body:
    "hello": "world"
  headers:
    Content-Type: application/json

上記の契約ではProducerに対し、GETメソッドで/helloのエンドポイントへアクセスされた際に200のレスポンスコードとapplication/jsonのコンテントタイプで、{"hello": "world"}Jsonを返却することを定義しています。
これは、単純な例ですが契約にはもっと複雑なことを記述することが可能です。

テストコードの自動生成

Spring Cloud Contractでは作成したContractに従ってテストを自動生成してくれます。
以下のコマンドを叩きプロジェクトをビルドします。

$ ./mvnw clean install

この際、現時点ではビルドは失敗しますが、一旦スルーして、target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/testsを見てみると、以下のテストが生成されていることが確認できます。

package org.springframework.cloud.contract.verifier.tests;

import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;

@SuppressWarnings("rawtypes")
public class ContractVerifierTest {

    @Test
    public void validate_hello_contract() throws Exception {
        // given:
            MockMvcRequestSpecification request = given();


        // when:
            ResponseOptions response = given().spec(request)
                    .get("/hello");

        // then:
            assertThat(response.statusCode()).isEqualTo(200);
            assertThat(response.header("Content-Type")).isEqualTo("application/json");

        // and:
            DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
            assertThatJson(parsedJson).field("['hello']").isEqualTo("world");
    }

}

ざっくりコードをみる限り、Contractに基づいたテストを行ってくれていますね。
テストはデフォルトでは、MockMvcを用いて生成されます。
設定を変えることによってJAX-RSクライアントや、Web-Fluxのアプリを作成している場合はWebClientを利用することも可能なようです。

Base Classを設定する

改めて、ビルドが失敗している原因をみてみます。
先程の./mvnw clean installのログ出力(抜粋)を以下に示します。

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test (default-test) on project contractproducer: There are test failures.
[ERROR] 
[ERROR] Please refer to /path/to/user/project/contractproducer/target/surefire-reports for the individual test results.
[ERROR] Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]

/target/surefire-reports配下にテストの結果が出力されているようなので見てみましょう。

org.springframework.cloud.contract.verifier.tests.ContractVerifierTest.txt

-------------------------------------------------------------------------------
Test set: org.springframework.cloud.contract.verifier.tests.ContractVerifierTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.232 s <<< FAILURE! - in org.springframework.cloud.contract.verifier.tests.ContractVerifierTest
validate_hello_contract  Time elapsed: 0.23 s  <<< ERROR!
java.lang.IllegalStateException: 
You haven't configured a MockMVC instance. You can do this statically

RestAssuredMockMvc.mockMvc(..)
RestAssuredMockMvc.standaloneSetup(..);
RestAssuredMockMvc.webAppContextSetup(..);

or using the DSL:

given().
        mockMvc(..). ..

    at org.springframework.cloud.contract.verifier.tests.ContractVerifierTest.validate_hello_contract(ContractVerifierTest.java:26)

エラーコードを読む限り、MockMvcの設定がされていなかったので怒られているみたいですね。
Spring Cloud Contractではこのように自動生成されたテストに対する設定をBase Classを用いて設定することができます。

MockMvcの設定を行なうためのクラスを作成します。

ContractTestBase.java

package dev.hirooka.contractproducer;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;

public class ContractTestBase {

    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(new HelloController());
    }
}

まだHelloControllerは作成していませんが、ポイントはMockMvcの設定をこのクラスで行っています。
次に、作成したContractTestBaseをSpring Cloud Contractのプラグインに認識させてやる必要があります。
pomの以下の箇所を追記します。

pom.xml

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.2.2.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
        <!--ここを追記する-->
        <baseClassForTests>dev.hirooka.contractproducer.ContractTestBase</baseClassForTests>
    </configuration>
</plugin>

configurationタグの子タグであるbaseClassForTestsに先ほど作成したBase Classをここに指定します。
これで、設定は完了です。

Producerの実装を行なう

Contractとテストを作成したので、Producerの実装を行います。
先程Bass Classの中で指定していたHelloControllerHello DTOを実装します。

Hello.java

public class Hello {

    public Hello(String hello) {
        this.hello = hello;
    }

    private String hello;

    public String getHello() {
        return hello;
    }

    public void setHello(String hello) {
        this.hello = hello;
    }
}

HelloController.java

public class HelloController {

    @GetMapping("/hello")
    public Hello greeting(){
        return new Hello("world");
    }
}

これでProducerの実装は完了です。
それではテストを実行してみましょう。

$ ./mvn test

(略)

[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  6.562 s
[INFO] Finished at: 2020-05-23T15:21:53+09:00
[INFO] ------------------------------------------------------------------------

無事成功しましたね。

一応、テストを落としてみましょう。
例えば、HelloControllerworldtypoしてwordと書いてしまった場合にテストを行なうと以下のような結果になります。

(以下抜粋)

[ERROR] Errors: 
[ERROR]   ContractVerifierTest.validate_hello_contract:35 » IllegalState Parsed JSON [{"...
[INFO] 
[ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  6.499 s
[INFO] Finished at: 2020-05-23T15:32:31+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test (default-test) on project contractproducer: There are test failures.
[ERROR] 
[ERROR] Please refer to /path/to/user/project/contractproducer/target/surefire-reports for the individual test results.
[ERROR] Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.

また例のごとく、結果がtarget/surefire-reports配下にあるので見て見ます。

dev.hirooka.contractproducer.ContractVerifierTest.txt

-------------------------------------------------------------------------------
Test set: dev.hirooka.contractproducer.ContractVerifierTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.321 s <<< FAILURE! - in dev.hirooka.contractproducer.ContractVerifierTest
validate_hello_contract  Time elapsed: 0.32 s  <<< ERROR!
java.lang.IllegalStateException: Parsed JSON [{"hello":"word"}] doesn't match the JSON path [$[?(@.['hello'] == 'world')]]
    at dev.hirooka.contractproducer.ContractVerifierTest.validate_hello_contract(ContractVerifierTest.java:35)

きちんとContractによるテストが行われているようですね。

Consumerの実装を行なう

Producerの実装が完了したので、今度はConsumerを実装していきます。
今回はConsumer側の実装もSpringを使って作っていこうと思います。
Spring Initializrで先ほどとほぼ同じプロジェクトを作成しますが、今度はContract Vifierは依存に追加せずに作成します。 その代わりに、Contract Stub Runnerを依存として追加してやります。

Consumer側のサービス

ConsumerはProducerに通信を行なう以下のServiceを持ちます。

@Service
public class ConsumerService {


    private RestTemplate restTemplate;

    public ConsumerService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public Hello getHello(){
        ResponseEntity<Hello> forEntity =
                restTemplate.getForEntity("http://localhost:8081/hello", Hello.class);
        return forEntity.getBody();
    }
}

RestTemplateのConfigやHelloクラスの実装は省略します。

Consumer側のテストを記述する

Spring Cloud Contractのcontract-stub-runnerを用いれば、作成したContractをもとにスタブサーバを自動で作成し立ち上げてくれます。
立ち上がるスタブはデフォルトではWireMockが利用されます。
スタブサーバの設定は@AutoConfigureStubRunnerを用いて行います。

ConsumerServiceTes.java

@SpringBootTest
@AutoConfigureStubRunner(
        stubsMode = StubRunnerProperties.StubsMode.LOCAL,
        ids = "dev.hirooka:contractproducer:+:stubs:8081"
)
public class ConsumerServiceTest {


    @Autowired
    private ConsumerService consumerService;

    @Test
    void consumerTest() {
        Hello hello = consumerService.getHello();
        assertThat(hello.getHello()).isEqualTo("world");
    }
}

Spring Cloud ContractはMavenリポジトリにデプロイされているContractを探してきて、スタブを作成してくれます。
idsの部分ではContractを含むプロジェクトのgroup-idartifact-idとそのバージョン、また、スタブのポートを指定します。

stubsMode = StubRunnerProperties.StubsMode.LOCALはローカルリポジトリを利用するための設定です。
もちろん、NEXUS等のMavenリポジトリを自分で立てて、リモートリポジトリからContractを探すことも可能です。その場合はLOCALではなく、REMOTEを利用します。

このテストを実行するとテストが成功して、以下のログが吐かれます。

2020-05-23 22:13:42.524  INFO 20251 --- [p1861338103-248] WireMock                                 : Admin request received:
127.0.0.1 - POST /mappings

Connection: [keep-alive]
User-Agent: [Apache-HttpClient/4.5.6 (Java/11.0.6)]
Host: [localhost:8081]
Content-Length: [358]
Content-Type: [text/plain; charset=UTF-8]
{
  "id" : "d0f0e53e-5079-4f0b-8f09-c12f19c0380e",
  "request" : {
    "url" : "/hello",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "{\"hello\":\"world\"}",
    "headers" : {
      "Content-Type" : "application/json"
    },
    "transformers" : [ "response-template" ]
  },
  "uuid" : "d0f0e53e-5079-4f0b-8f09-c12f19c0380e"
}

WireMockに対して、Contractに基づく設定が行われているのがわかりますね。
これで、Consumer側の実装も完了しました。

Contractを変更してみる

例えば、Contractが以下のように変更されたことを想定します。

request:
  method: GET
  url: /hello
  matchers:
response:
  status: 200
  body: # Contractの内容が変更
    "message": "hello spring contract"
  headers:
    Content-Type: application/json

この場合、Contractに基づいてテストされるProducerは落ちることはもちろん、 新しいContractがMavenリポジトリデプロイされ次第、Consumer側で建てられるスタブも自動的に変わりテストが落ちるようになるので、契約が変更されたことが検知できるようになります。

ProducerはContractに基づいた実装が保証されているので、Consumer側は単にスタブを用いたテストを行なうだけでよくなります。

感想

今回はレスポンスボディとして、スタティックな値を返すだけのContractを記述しましたがregrexを利用してもっと複雑な記述が可能です。
また、少し紹介しましたが、JVM言語以外での利用の際のプラクティスなどもあるようなので、そちらも今後試してみたいです。

参考資料