spring-boot
pact
CDC

Spring Boot x Pact-jvmでCDC(コンシューマ駆動契約)をやってみた

このドキュメントについて

  • Spring bootでPactを使ってCDC(コンシューマ駆動契約)する方法をまとめた
  • Spring Cloud ContractではなくPact-jvmをつかった

リポジトリ

電池切れそうだからあとでやる

依存

どっちもライセンスはApache

対象 依存内容
Consumer pact-jvm-consumer-jnit
Provider pact-jvm-provider-spring

ここでの前提

  • Pact Brokerがたててある。今回はローカルのポート80で動かす設定にした
    • あとで別でまとめます
  • ConsumerもProviderもSpring bootのプロジェクトである
  • 下記の要件を満たす

要件

下記のようなAPIを使うConsumerとProviderを実装、CDCする

  • /v1/providerがエントリポイント
  • クエリパラメータはnameのみでmitsuyaが来たときだけデータを返す
  • データが有るときとないときで下記のようなJSONを返却する

あるとき

{"hasData": true}

ないとき

{"error": {"message": "no data"}}

Consumer側でCDCする

とりあえず動かす

Clientの実装

package com.example.javapactsampleconsumer;

import org.springframework.web.client.RestTemplate;

import java.util.Map;

public class ConsumerClient {

    public Map get(String url) {
        RestTemplate restTemplate = new RestTemplate();
        return restTemplate.getForObject(url, Map.class);
    }
}

テストの実装

package com.example.javapactsampleconsumer;

import au.com.dius.pact.consumer.ConsumerPactBuilder;
import au.com.dius.pact.consumer.PactVerificationResult;
import au.com.dius.pact.model.MockProviderConfig;
import au.com.dius.pact.model.RequestResponsePact;
import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

import static au.com.dius.pact.consumer.ConsumerPactRunnerKt.runConsumerTest;
import static org.junit.Assert.assertEquals;


public class ConsumerControllerContractTest {

    @Test
    public void testHasData() {
        Map headers = new HashMap();
        headers.put("Content-type", "application/json");
        RequestResponsePact pact = ConsumerPactBuilder
                .consumer("sampleConsumer") // consumer名を設定
                .hasPactWith("sampleProvider") // provider名を設定
                .given("exists data") // あとでprovideでテスト書くときに仕様する文字列
                .uponReceiving("データが有るケース") // このケースの概要を設定
                .path("/v1/provider") // エントリポイント
                .query("name=mitsuya") // クエリパラメータ
                .method("GET") 
                .willRespondWith() // ここからレスポンスの定義
                .status(200) 
                .headers(headers)
                .body("{\"hasData\": true}") // 返却するデータ
                .toPact();

        MockProviderConfig config = MockProviderConfig.createDefault();
        PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
            Map expectedResponse = new HashMap();
            expectedResponse.put("hasData", true);

            assertEquals(new ConsumerClient().get(mockServer.getUrl() + "/v1/provider?name=mitsuya"),
                    expectedResponse);
        });
        assertEquals(PactVerificationResult.Ok.INSTANCE, result);
    }

    @Test
    public void testNoData() {
        Map headers = new HashMap();
        headers.put("Content-type", "application/json");
        RequestResponsePact pact = ConsumerPactBuilder
                .consumer("sampleConsumer")
                .hasPactWith("sampleProvider")
                .given("no data")
                .uponReceiving("データがないケース")
                .path("/v1/provider")
                .query("name=hoge")
                .method("GET")
                .willRespondWith()
                .status(200)
                .headers(headers)
                .body("{\"error\": {\"message\": \"no data\"}}")
                .toPact();

        MockProviderConfig config = MockProviderConfig.createDefault();
        PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
            Map expectedResponse = new HashMap();
            Map innerReponse = new HashMap();
            innerReponse.put("message", "no data");
            expectedResponse.put("error", innerReponse);
            assertEquals(new ConsumerClient().get(mockServer.getUrl() + "/v1/provider?name=hoge"),
                    expectedResponse);
        });
        assertEquals(PactVerificationResult.Ok.INSTANCE, result); // エラーがないか確認
    }
}

gradleの設定に下記を追加

test {
    systemProperties['pact.rootDir'] = "$buildDir/pacts" // Pactファイルの出力先
}

動かした

動かすと下記のようなPactファイルが出力される


{
    "provider": {
        "name": "sampleProvider"
    },
    "consumer": {
        "name": "sampleConsumer"
    },
    "interactions": [
        {
            "description": "\u30c7\u30fc\u30bf\u304c\u6709\u308b\u30b1\u30fc\u30b9",
            "request": {
                "method": "GET",
                "path": "/v1/provider",
                "query": "name=mitsuya"
            },
            "response": {
                "status": 200,
                "headers": {
                    "Content-type": "application/json"
                },
                "body": {
                    "hasData": true
                }
            },
            "providerState": "exists data"
        },
        {
            "description": "\u30c7\u30fc\u30bf\u304c\u306a\u3044\u30b1\u30fc\u30b9",
            "request": {
                "method": "GET",
                "path": "/v1/provider",
                "query": "name=hoge"
            },
            "response": {
                "status": 200,
                "headers": {
                    "Content-type": "application/json"
                },
                "body": {
                    "error": {
                        "message": "no data"
                    }
                }
            },
            "providerState": "no data"
        }
    ],
    "metadata": {
        "pact-specification": {
            "version": "2.0.0"
        },
        "pact-jvm": {
            "version": "3.5.10"
        }
    }
}

解説

  • ConsumerPactBuilderを使って利用するAPIの定義をしてる
  • runConsumerTest内でモック化されたAPIを利用してテストを行っている
  • 出力されるPactファイルをPact Brokerに登録してProviderでも使えるようにする
  • APIの返却されるケース毎にテストを行ったが、まとめてやる方法もあるみたい(試したけどなんかうまく動かなった・・・)
  • .given()で設定した文字列を使ってProviderでテストを行う。詳細はProviderで

ProviderでCDCする

とりあえず動かす

コントローラーの実装

package com.example.javapactsampleprovider;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public class ProviderController {

    @GetMapping("/v1/provider")
    public Map get(String name) {
        Map response = new HashMap();
        if (!"mitsuya".equals(name)) {
            Map innerResponse = new HashMap();
            innerResponse.put("message", "no data");
            response.put("error", innerResponse);
            return response;
        }
        response.put("hasData",true);
        return response;
    }
}

テストの実装

package com.example.javapactsampleprovider;

import au.com.dius.pact.provider.junit.Provider;
import au.com.dius.pact.provider.junit.State;
import au.com.dius.pact.provider.junit.loader.PactBroker;
import au.com.dius.pact.provider.junit.target.HttpTarget;
import au.com.dius.pact.provider.junit.target.Target;
import au.com.dius.pact.provider.junit.target.TestTarget;
import au.com.dius.pact.provider.spring.SpringRestPactRunner;
import org.junit.runner.RunWith;
import org.springframework.boot.test.mock.mockito.MockBean;

import java.util.HashMap;
import java.util.Map;

import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;

@RunWith(SpringRestPactRunner.class)
@Provider("sampleProvider") // provider名を指定
@PactBroker(host = "localhost", port = "80") // Pact Brokerからデータ引っ張ってくる
public class ProviderControllerContractTests {

    @MockBean
    private ProviderController providerController;

    @State("exists data")
    public void testExistsData() {
        Map expected = new HashMap();
        expected.put("hasData", true);
        reset(providerController);
        when(providerController.get("mitsuya")).thenReturn(expected);
    }

    @State("no data")
    public void testNoData() {
        Map expected = new HashMap();
        Map innerExpected = new HashMap();
        innerExpected.put("message", "no data");
        expected.put("error", innerExpected);
        reset(providerController);
        when(providerController.get("hoge")).thenReturn(expected);
    }

    @TestTarget
    public final Target target = new HttpTarget(8080); // モックサーバのポートを指定
}


解説

  • コンシューマで定義した仕様にそったテストを書くことでちゃんと結合できるよねってことを担保している
  • Pact Brokerからどういう条件があるか引っ張ってくる。それに沿ったテストしてないと落ちる
  • @StateでConsumer側の.given()で設定したテストケースと紐付けている
  • @TestTargetはモックサーバのポートを指定していて、なおかつモックサーバ動いてないと落ちる。モックサーバの作り方は今度調べる。今回はめっちゃ適当につくってしまったけどいいやり方あるはず

まとめ

  • コンシューマ側で定義した仕様をPactファイルを吐き出してプロデューサー側でそれを使うことで互いの仕様をあわせている
  • コンシューマとプロデューサーの紐付けをするPactファイルはPact Brokerで行っている。これがないと後々きつそう
  • Pact Brokerとモックサーバの建て方はまた別途まとめる
  • 公式の資料見てがんばればどうにかなる

参考にした資料