golang - Kotlinのmicroservice構成のConsumer-Driven Contract testingをpactをつかって作ってみた
今回はConsumer-Driven Contract testingのサンプルを作ってみました。以前のSelenideを使ったE2Eの記事の流れからConsumer-Driven Contract testingも試してみようというモチベーションです。
Consumer-Driven Contract testingとは
Consumer-Driven Contract(以下、CDC)で検索すると定義についての記事がたくさん見つかりますので詳細な説明は他の記事にお任せします。
- Consumer-Driven Contracts testingを徹底解説! - Qiita
- Consumer-Driven Contracts: A Service Evolution Pattern
CDCテストはmicroservice architectureをベースに複数のmicroserviceでサービス全体を構築しサービスが成長する過程で直面する問題に向き合うためのテスト手法の1つ、というのが私の理解です。
どういった問題に直面するか
- 複雑化するmicroservice間の依存関係
- 依存関係が正常に保たれているかを検証するためのコスト
- 不安要素の蓄積がmicroserviceの拡張難易度を上げる
これらの問題要素がデスマーチのように回り始めるとmicroserviceの拡張が止まり更にサービス全体の成長が止まります。
問題を解消するには
多くのmicroservice間の連携はAPIの提供と利用で成り立っています。
2つのmicroserviceの関係はAPIを提供する側(Provider)、APIを利用する側(Consumer)になります。またConsumerのmicroserviceの機能はProviderが提供するAPIを基盤として動きます。このように整理するとProviderが提供するAPIに不備があったり不明なAPI仕様があったりするとConsumerは困ってしまいます。
そのためにお互いにAPIのルール(Contract)を定義します。そしてルールの定義をConsumer側が行い(Consumer-Driven Contract)、Providerがルールを守ることでmicroservice間の連携を保ちます。
このCDCテストを複数のmicroservce間で継続的に行うことで先に挙げた問題を解消します。
Pact
Pactはmicroservce間のCDCテストを順序立て実行するためのフレームワークです。
github.com
今回はPactをインプリメントした次のプロジェクトを利用してCDCテストを作りました。
github.com
Pactをインプリメントした各種言語のライブラリを使う
次のプロジェクトに各種言語のプロジェクトがまとまっていますので参考にしました。
https://github.com/DiUS/pact-jvm
各ライブラリにはCDCテストをするために次のような仕組みを用意しています。
- ConsumerがProviderのAPIをモック化する仕組み
- ConsumerとProviderがルールを共有するためのPactファイルを生成する仕組み
- ProviderがAPI仕様を満たしているかテストする仕組み
golangのライブラリ
kotlinのライブラリ
Pact仕様はversion1.1を使う
go-langのライブラリが1.1までの対応のため今回作ったテストもversion1.1を使っています。
- GitHub - pact-foundation/pact-specification at version-1.1
- 現在はVersion4まで開発が進んでいます。その他のVersionはこちらで確認できます。
Consumer-Driven Contract testをつくる
まずmicroserviceの定義ですがConsumerのmicroserviceはgolang、Providerのmicroserviceはkotlinで構築しました。
シンプルなユーザ情報を取得するAPIをProvider(kotlin)が提供しConsumer(golang)がAPIを利用するという構成でCDC testをつくっていきます。
Consumerのコード(golang)
Provider APIへ接続するClient定義です。ユーザIDを受け取りAPIリクエストを送るシンプルなつくりです。
package client import ( "encoding/json" "fmt" "net/http" ) type UserClient struct { baseURL string } type User struct { Name string } func (c *UserClient) GetResource(id int) (*User, error) { url := fmt.Sprintf("%s/user/%d", c.baseURL, id) req, _ := http.NewRequest("GET", url, nil) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var res User decoder := json.NewDecoder(resp.Body) if err := decoder.Decode(&res); err != nil { return nil, err } return &res, nil }
上記のClinetのリクエストとレスポンスをテストコードでモック化しAPI仕様のバリエーションを作成しPactファイルを生成します。
次のコードはテストコードです。
package client import ( pact "github.com/SEEK-Jobs/pact-go" "github.com/SEEK-Jobs/pact-go/provider" "net/http" "testing" ) func buildPact() pact.Builder { return pact. NewConsumerPactBuilder(&pact.BuilderConfig{PactPath: "../../pacts"}). ServiceConsumer("consumer_user_client"). HasPactWith("provider_user_client") } func Test_ContractUserClientProvider_StatusIsOk(t *testing.T) { builder := buildPact() ms, msUrl := builder.GetMockProviderService() request := provider.NewJSONRequest("GET", "/user/1192", "", nil) header := make(http.Header) header.Add("content-type", "application/json") response := provider.NewJSONResponse(200, header) response.SetBody(`{"Name": "1192-User"}`) if err := ms.Given("fetch user by id 1192"). UponReceiving("get request for user with id 1192"). With(*request). WillRespondWith(*response); err != nil { t.Error(err) t.FailNow() } // Test request user client client := &UserClient{baseURL: msUrl} if _, err := client.GetResource(1192); err != nil { t.Error(err) t.FailNow() } // Verify registered interaction if err := ms.VerifyInteractions(); err != nil { t.Error(err) t.FailNow() } // Clear interaction for this test scope, if you need to register and verify another interaction for another test scope ms.ClearInteractions() //Finally, build to produce the pact json file if err := builder.Build(); err != nil { t.Error(err) } }
- buildPact()でPactファイルの生成フォルダの指定とConsumer名称のconsumer_user_clientとProvider名称のprovider_user_clientを定義しています。
- Test_ContractUserClientProvider_StatusIsOk(t *testing.T)ではUserClientのリクエストとレスポンスを定義しモック化しPactファイルを生成します。
- fetch user by id 1192の文字列はprovider stateとして定義されます。このprovider stateの数がAPI仕様の数と一致するようにUserClientのリクエストとレスポンスを定義します。例えばデータが存在しない404エラーレスポンスや入力値が不正な400エラーレスポンスが必要であればprovider stateとして定義します。
テストを実行してPactファイルを生成します。
$ go test -v ./... === RUN Test_ContractUserClientProvider_StatusIsOk --- PASS: Test_ContractUserClientProvider_StatusIsOk (0.00s) PASS ok github.com/nsoushi/cdc-test/pact-go-consumer/client 0.014s $ ls ../pacts consumer_user_client-provider_user_client.json
生成したPactファイルは次のようになりました。
{ "consumer": { "name": "consumer_user_client" }, "provider": { "name": "provider_user_client" }, "interactions": [ { "provider_state": "fetch user by id 1192", "description": "get request for user with id 1192", "request": { "method": "GET", "path": "/user/1192" }, "response": { "body": { "Name": "1192-User" }, "headers": { "Content-Type": "application/json" }, "status": 200 } } ], "metaData": { "pactSpecificationVersion": "1.1.0" } }
このPactファイルをProvider(kotlin)が読み込みProvider側で更にテストを実行します。
Providerのコード(kotlin)
@RunWith(PactRunner::class) @Provider("provider_user_client") @PactFolder("pacts") @WebAppConfiguration open class ContractUserTest { lateinit var wireMockServer: WireMockServer companion object { // mock port private val port = 8080 @TestTarget lateinit var target: Target @BeforeClass @JvmStatic fun setUpService() { target = HttpTarget(port) } } @Before fun before() { wireMockServer = WireMockServer(port) wireMockServer.start() WireMock.configureFor(port) } @State("fetch user by id 1192") open fun toDefaultState() { val path = "/user/1192" val target = UserController() val mvc = MockMvcBuilders.standaloneSetup(target).build() val mvcResult = mvc.perform(MockMvcRequestBuilders.get(path)).andReturn() WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(path)) .willReturn(WireMock.aResponse() .withStatus(mvcResult.response.status) .withHeader("Content-Type", mvcResult.response.getHeader("Content-Type")) .withBody(mvcResult.response.contentAsString))) } }
- @RunWith(PactRunner::class)/テスト実行クラスにPactRunnerクラスを指定
- @Provider("provider_user_client")/Pactファイルから参照するProvider名称を指定
- @PactFolder("pacts")/Pactファイルの保存ディレクトリを指定
- private val port = 8080/PactRunnerクラスはテスト実行時にポートをポーリングし実行したリクエストとレスポンスを監視します。このコードでポーリングするポートを指定します。
- @State("fetch user by id 1192")/Pactファイルにあるinteractionsの1つのprovider_stateを指定しています。このメソッドのスコープ内でAPIのリクエストを実行しレスポンスをモック化し定義したAPIの振る舞いを再現することでAPI定義と一致しているか検証が行われます。(ポーリングの仕組みがこのリクエストとレスポンスを検証します)
Consumerが定義したPactファイルをProviderがリクエストとレスポンスをモック化してAPI定義を満たしているか検証します。今回はPactファイルにあるinteractionsが1つのみでしたが複数ある場合はProviderのテストでも複数の@Stateを作り検証します。
このようにConsumerとProviderの両者で共通の定義を不足なく検証を行うことができます。
まとめ
2つの異なる言語のmicroservice間のCDCテストをPactの仕組みを使いテストを行いました。ConsumerとProviderで最新の定義ファイルを共有することでデプロイ時などにテストを走らせることでmicroservice間の連携エラーになることを防げます。
今回は定義ファイルをファイルシステムで参照しましたがgithubにファイルをプッシュし参照する方法やbrokerと呼ばれるPactファイルの中継地点を介する方法などPactファイルを参照する様々な仕組みがあります。