Spring Cloud ContractによるCDCを試してみる

Spring

マイクロサービスアーキテクチャのアプリケーションに対するテストのアプローチとしてCDCというのがあるそうです。日本語にすると消費者主導契約。なんの話だか全くわかりませんが、Spring Cloud Contractを使用して実現できるということなので少し試してみました。

Spring Cloud Contract

こちらですね。

Spring Cloud Contract

CDCそのものの解説は・・・ちょっと大変なのでこの辺りの説明がわかりやすいかもしれません。

Consumer-Driven Contracts testingを徹底解説! - Qiita
この記事は ( の14日目の記事です。 ## はじめ...

Spring Cloud Contractのリファレンスを読む限りざっくりできることはこんな感じのようです。

マイクロサービスアーキテクチャにおける各アプリケーション(サービス)の中で、

  • Provider(呼ばれる側)アプリケーションにおいてConsumerが定めたContract(アプリケーションの仕様定義)を元にProviderアプリケーションのテストコードを生成する。
    ※Consumerが定めた仕様に従っている事を保証する
  • Consumer(呼ぶ側)アプリケーションのテストにおいてContractを元にMockサービス(スタブ)を生成し、テストを実行する。
    ※Contractに準拠したスタブを自動生成してテストを行う

ということで、各アプリケーションでContractを共有してそれを守っている限りにおいて各サービスが認識している仕様がずれたりしないで開発が出来ますね、ということのようです。

この仕組を用いて、

  • 全サービスをデプロイしてテストを行う
  • ProviderのスタブをConsumerが作成してテストを行う

というよくあるテスト方法のデメリットを解消しよう、というコンセプトの模様。

サンプルアプリケーション

ConsumerとProviderのRESTアプリケーションが一つずつの簡単な構成を例に説明していきます。実物はこちらです。

kawakamitor/blog-materials
Contribute to kawakamitor/blog-materials development by creating an account on GitHub.

各アプリケーションはこんな感じになっています。”簡単なサンプル”を思いつくセンスがあまり無いようなので細かいところは多めに見てやってください(^_^;)

Provider

ユーザのID、名前、年齢を管理しているアプリケーション。こんな感じのテーブルを持っています。データベースはH2を使用。

idBIGINTNOT NULL, AUTO_INCREMENT,PRIMARY KEY
nameVARCHAR(255)NOT NULL
ageINTNOT NULL

Path Variableでidを指定して↑の1行分のJSONを返すGETメソッドを1つ保持。

Consumer

ユーザの操作履歴を保持するアプリケーション。テーブルはこんな感じ。

idBIGINTNOT NULL,PRIMARY KEY
nameVARCHAR(255)NOT NULL,PRIMARY KEY
createAtTIMESTAMPNOT NULL
userActionCHAR(30)NOT NULL

idとuserActionを指定してレコードを追加するPOSTメソッドを一つ保持。レコードを追加するときにProviderのGETメソッドを使用してnameを取得する。

Path Variableでidを指定してidに紐づくuserActionをまとめて取得するGETメソッドを一つ保持。

ライブラリ

実装に使用したもののバージョン等はこんな感じです。

  • kotlin(1.2.51)
  • java(1.8.0_171)
  • spring boot(2.0.3.RELEASE)
  • spring cloud contract(2.0.0.RELEASE)
  • Gradle(4.8.1)

実装手順

Providerの実装

ContractはProviderアプリケーションが保持し、Mavenの(ローカル)リポジトリ上にinstallされたProviderをConsumerが参照するのが基本的なパターンのようなので、先にProviderを実装する必要があります。

build.gradleの設定

buildscriptspring-cloud-contract-gradle-pluginを追加

dependencies {
    // 省略
    classpath("org.springframework.cloud:spring-cloud-contract-gradle-plugin:${springCloudContractVersion}")
    // 省略
}
apply plugin: 'spring-cloud-contract'

dependencyManagementspring-cloud-contract-dependenciesを追加

apply plugin: 'io.spring.dependency-management'
dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-contract-dependencies:2.0.0.RELEASE'
    }
}

dependenciesspring-cloud-starter-contract-verifiergroovy-allを追加

dependencies {
    // 省略
    testCompile('org.codehaus.groovy:groovy-all')
    testCompile('org.springframework.cloud:spring-cloud-starter-contract-verifier')
    // 省略
}

リファレンスでの説明はこの辺り。

Spring Cloud Contract

Contractの作成

ProviderプロジェクトにGroovyまたはYAMLでContractを作成します。デフォルトのパスはsrc/test/resources/contractsになっています。

配下のディレクトリに制約は無いように見えるので、RestControllerごとにディレクトリを彫り、エンドポイント毎にファイルを作成する感じが良いかも?しれません。その辺はお好みで。

今回作成したContract(groovy)はこちら。

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'GET'
        url $(consumer(regex('/user/[1-5]{1}')))
    }
    response {
        status 200
        headers {
            header('Content-Type', 'application/json;charset=UTF-8')
        }
        body(
                "id": $(producer(regex('[0-9]{1}'))),
                "name": $(producer(regex('[a-zA-Z]{1,255}'))),
                "age": $(producer(regex('[0-9]{1,2}')))
        )
    }
}

$(consumer(regex('/user/[1-5]{1}')))の部分の正規表現は、Providerのテストコードを生成する際にマッチングする範囲でリクエストパスを生成してくれます。

$(producer(regex('[0-9]{1}')))のところは、レスポンスの定義なので、レスポンスがこの正規表現にマッチするかをアサーションするテストコードが生成されます。

Contractの定義方法はそうそう説明しきれるものではないので公式のリファレンスを参考に組み立ててみてください。

Spring Cloud Contract

基底テストクラスの作成

ProviderでContractを元に生成されるテストクラスの基底クラスを作成します。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ProviderApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ContractTestBase {

    @Autowired
    UserRestController userRestController;

    @Before
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(userRestController);
    }

    @Test
    public void contextLoads() {
    }
}

RestAssuredMockMvcで設定する対象のControllerをテスト用のコンテキストから取るのが良いかと思い@SpringBootTestを使いました。もしかしてバッドなやり方ですかねぇ・・・どうなんだろう。

併せて、作成した基底クラスをbuild.gradleに設定します。

contracts {
    baseClassForTests = 'com.example.provider.ContractTestBase'
}
処理の実装にはkotlinを使用する方針としていたのですが、基底クラスにはkotlinで実装したものでは動作しないようでしたのでjavaでの実装としています。制約なのかどうかまでは確認しきれていません。

テストの実行

ここまで設定しておくと、Providerのビルド時にテストコードが生成され、実行されます。テストコードの生成を行うgradleタスクはgenerateContractTestsになります。

生成されたテストコードはこちら。

public class UserRestControllerTest extends ContractTestBase {

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

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

                // then:
                        assertThat(response.statusCode()).isEqualTo(200);
                        assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8");
                // and:
                        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
                        assertThatJson(parsedJson).field("['id']").matches("[0-9]{1}");
                        assertThatJson(parsedJson).field("['name']").matches("[a-zA-Z]{1,255}");
                        assertThatJson(parsedJson).field("['age']").matches("[0-9]{1,2}");
        }
}

リクエストはPath Variableの部分がランダムに設定されたパスが生成されています。また、レスポンスのアサーションについてはContractに設定した部分が検証されています。Contractの書き方はちゃんとやろうとすると難しそうですね。

ConsumerはMavenリポジトリ上のProviderを参照するため、mavenプラグインを使用して参照されるリポジトリにインストールしておく必要があります。

Consumerの実装

続いてConsumer側の実装です。Consumer側ではスタブの自動生成の設定を行います。

build.gradleの設定

spring-cloud-contract-gradle-pluginの追加、spring-cloud-contract-dependenciesの追加はProviderと同様です。dependenciesにはspring-cloud-starter-contract-stub-runnerを追加します。

dependencies {
    // 省略
    testCompile('org.springframework.cloud:spring-cloud-starter-contract-stub-runner')
    // 省略
}

テストコードの作成

ConsumerのテストコードはRest-Assured等を使用してJUnitコードしますが、そのコードに対してスタブ生成の記述を行います。

@RunWith(SpringRunner::class)
@SpringBootTest(classes = [ConsumerApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureStubRunner(ids = ["com.example:provider:+:stubs:8081"], stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class ConsumerApplicationTests {

    // 省略

    @Test
    fun postUserAction01() {
        // 省略
    }

}

@AutoConfigureStubRunnerを使用してスタブ生成の対象を指定します。ids属性にはスタブを生成するアプリケーションのgroup id,artifact id,version,port番号を配列で指定します。バージョンに+を指定するとリポジトリ上の最新バージョンを取得します。スナップショットを避けるオプションも用意されているようです。

ここで指定するポート番号はConsumerアプリケーションがプロパティなどに保持しているProviderアプリケーションのポート番号と一致している必要があります。

stubsModeには参照先リポジトリの指定を行います。上記の例ではLOCAL(ローカルリポジトリ)を指定していますが、クラスパスおよびリモートリポジトリも指定可能です。

この設定を行うことで、Providerが保持しているContractを元にスタブを生成し、テストにおけるConsumerからのアクセスはスタブへ向けて行われることになります。

なお、テストを実行してスタブを生成する際にはこのようなログが出ます。

2018-07-13 20:39:57.272  INFO 6096 --- [           main] o.s.c.c.s.AetherStubDownloaderBuilder    : Will download stubs and contracts via Aether
2018-07-13 20:39:57.275  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Remote repos not passed but the switch to work offline was set. Stubs will be used from your local Maven repository.
2018-07-13 20:39:57.390  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Desired version is [+] - will try to resolve the latest version
2018-07-13 20:39:57.404  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is [0.0.1-SNAPSHOT]
2018-07-13 20:39:57.408  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact [com.example:provider:jar:stubs:0.0.1-SNAPSHOT] to C:\Users\User\.m2\repository\com\example\provider\0.0.1-SNAPSHOT\provider-0.0.1-SNAPSHOT-stubs.jar
2018-07-13 20:39:57.409  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacking stub from JAR [URI: file:/C:/Users/User/.m2/repository/com/example/provider/0.0.1-SNAPSHOT/provider-0.0.1-SNAPSHOT-stubs.jar]
2018-07-13 20:39:57.425  INFO 6096 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacked file to [C:\Users\User\AppData\Local\Temp\contracts3048922511173535796]
2018-07-13 20:39:58.252  INFO 6096 --- [           main] wiremock.org.eclipse.jetty.util.log      : Logging initialized @5518ms
2018-07-13 20:39:58.315  INFO 6096 --- [           main] w.org.eclipse.jetty.server.Server        : jetty-9.2.z-SNAPSHOT
2018-07-13 20:39:58.328  INFO 6096 --- [           main] w.o.e.j.server.handler.ContextHandler    : Started w.o.e.j.s.ServletContextHandler@7910f355{/__admin,null,AVAILABLE}
2018-07-13 20:39:58.328  INFO 6096 --- [           main] w.o.e.j.server.handler.ContextHandler    : Started w.o.e.j.s.ServletContextHandler@12209826{/,null,AVAILABLE}
2018-07-13 20:39:58.338  INFO 6096 --- [           main] w.o.e.j.s.NetworkTrafficServerConnector  : Started NetworkTrafficServerConnector@674184d{HTTP/1.1}{0.0.0.0:8081}
2018-07-13 20:39:58.338  INFO 6096 --- [           main] w.org.eclipse.jetty.server.Server        : Started @5605ms
2018-07-13 20:39:58.339  INFO 6096 --- [           main] o.s.c.contract.stubrunner.StubServer     : Started stub server for project [com.example:provider:0.0.1-SNAPSHOT:stubs] on port 8081
2018-07-13 20:39:58.624  INFO 6096 --- [tp1478683866-29] /__admin                                 : RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.AdminRequestHandler. Normalized mapped under returned 'null'
2018-07-13 20:39:58.650  INFO 6096 --- [tp1478683866-29] WireMock                                 : Received request to /mappings with body {
  "id" : "cb5e8674-38d3-43e2-bf0c-c0f7e9b8e37b",
  "request" : {
    "url" : "/ping",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "OK"
  },
  "uuid" : "cb5e8674-38d3-43e2-bf0c-c0f7e9b8e37b"
}
2018-07-13 20:39:58.771  INFO 6096 --- [tp1478683866-28] WireMock                                 : Received request to /mappings with body {
  "id" : "22da700b-0dd5-49ba-a3c7-ec0f36d17475",
  "request" : {
    "url" : "/health",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "OK"
  },
  "uuid" : "22da700b-0dd5-49ba-a3c7-ec0f36d17475"
}
2018-07-13 20:39:58.836  INFO 6096 --- [tp1478683866-32] WireMock                                 : Received request to /mappings with body {
  "id" : "5641c7d6-dc2c-456b-968c-3f6b18b3c2f9",
  "request" : {
    "urlPattern" : "/user/[1-5]{1}",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "{\"name\":\"jR\",\"id\":\"3\",\"age\":\"82\"}",
    "headers" : {
      "Content-Type" : "application/json;charset=UTF-8"
    },
    "transformers" : [ "response-template" ]
  },
  "uuid" : "5641c7d6-dc2c-456b-968c-3f6b18b3c2f9"
}
2018-07-13 20:39:58.850  INFO 6096 --- [           main] o.s.c.c.stubrunner.StubRunnerExecutor    : All stubs are now running RunningStubs [namesAndPorts={com.example:provider:0.0.1-SNAPSHOT:stubs=8081}]

リポジトリからProviderをダウンロードしてスタブを生成しているのが見て取れます。

まとめ

Spring Cloud Contractを使用したテストの実装方法について解説してみました。他にもフレームワークがあるような(Pact)ことも見かけましたが、Java系だとSpring Bootが流行っている事もあってSpring Cloud Contractが有力なんでしょうか?世の中の動向は正確にはわかりませんが。。。

ProviderがContractに違反するような改修をするとテストが落ちるからそこは守られる、ConsumerはContractを信じてテストしてるから両者の齟齬は発生しない、ということなのでしょうけれど、Contractをちゃんと作るっていうのがなんか難しい感じがします。実際どうなんでしょうねぇ。

さて、今回説明した内容は当然ごく一部で、私もリファレンスを見ながらちょこっと試してみた程度ですので、詳しいことは公式のリファレンスを見てくださいということにはなるのですが何かのお役に立てば幸いです。

追記(2018/07/18)

Springの公式ブログでちょこっとご紹介いただきました。

This Week in Spring - July 17th, 2018
Hi Spring fans and welcome to another installment of This Week in Spring! Can you believe we’re already midway through the year?? Stunning. This week I’m in Sa...

とのこと。ありがとうございます。

この記事に対するコメント