Spring Cloud Contract を試してみた

Groovy, Java, Microservices, Spring Boot, Spring Cloud Contract, テスト駆動開発, プログラミング

Photo By Ahmad M via GATAG

こんにちは、ソリューション開発部の桑原です。

現在私は、分科会活動として「マイクロサービスアーキテクチャ」の調査・検討を進めています。

本日は検討テーマの一つであるConsumer Driven Contract Testについて書いていきたいと思います。

Consumer Driven Contractとは

コンシューマ駆動契約はアーキテクチャに対するテスト駆動開発のようなもの

アーキテクチャレベルまで進歩させたテスト駆動開発

https://www.infoq.com/jp/news/2017/05/spring-cloud-contract

モノリシックなアプリケーションをサービス単位に分割などして、RESTなどで呼び出しているコードのテストを行おうとした場合、テストの実施方法として

  • 呼び出す全てのサービスをMock化
  • 呼び出す全てのサービスをDeploy

といった方法が考えられますが、

  • サービスのMock化
    • 実際には意味のないMockの作成をしてしまう危険性
    • 呼び出し先のサービスが変更されている場合、それに気付けない。(本番でエラーが発生して気がつくなど)
  • サービスのDeploy
    • 環境(NWやDB)、デプロイなどの準備が大変(開発スピードの低下)
    • 実行に時間がかかる

といったデメリットがあります。

Consumer Driven Contract testing

上に挙がっているデメリットへの対応策として生まれてきたのがConsumer Driven Contractというテスト駆動の考え方のようです。

サービスの呼び出し元をConsumer、呼び出し先をProviderと呼び、以下の手順によってConsumerとProviderの確からしさを検証する手法とのことです。

  1. ConsumerがProviderに期待する、特定の要求に対する応答をContractとする
  2. ProviderとConsumerはそのContractに合意する
  3. ConsumerはContractを元にMock化してしてテストを実施、ProviderはContractを守っていることを示すテストを実施

上記の方法によってConsumerはMockを使ったテストを行いつつ、Providerに変更が加わったような場合もテスト結果によってすぐにわかる。といった仕組みになっています。

Spring Cloud Contractを使ってみる

今回はConsumer Driven Contract フレームワークの一つ、Spring Cloud Contractを実際に使ってみたいと思います。

下図のようにclientがConsumerへアクセスすると、ConsumerがProviderへ商品リストを問い合わせて返却する。といったとてもシンプルなコードで試してみます。

シーケンス図

Spring Bootで起動しており、Consumer、Provider、Goodsは以下の通りです。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@RestController
@RequestMapping(value = "/")
public class ConsumerController {
    private final RestTemplate restTemplate;
    private final String producerUrl;
    @Autowired
    public ConsumerController(final RestTemplateBuilder restTemplateBuilder,
                              @Value("${producer.url}") final String producerUrl) {
        this.restTemplate = restTemplateBuilder.build();
        this.producerUrl = producerUrl;
    }
    @RequestMapping(method = RequestMethod.GET)
    public List<Goods> index() {
        ParameterizedTypeReference<List<Goods>> responseType = new ParameterizedTypeReference<List<Goods>>() {
        };
        return restTemplate.exchange(producerUrl + "/goods", HttpMethod.GET, null, responseType).getBody();
    }
}
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping(value = "/goods")
public class ProviderController {
    @RequestMapping(method = RequestMethod.GET)
    public ResponseEntity<List<Goods>>index() {
        return new ResponseEntity<>(Arrays.asList(new Goods(1, "potato"), new Goods(2, "tomato")), HttpStatus.OK);
    }
}
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Goods { 
    private int id;
    private String name;
}

それでは、実際にテストコードを書いていきたいと思います。

まずはConsumerとProviderの間に生まれるContractを下のようなGroovy DSLに記述していきます。置き場所はProvider側のプロジェクトの、特に指定がない限り/src/test/resources/contractsとなります。

ファイル名はgoodsContract.groovyとしました。

package contracts
org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'GET'
        urlPath '/goods'
    }
    response {
        status 200
        headers {
            header('Content-Type': 'application/json;charset=UTF-8')
        }
        body("""[{
                  "id": "1",
                  "name": "potato"
              }, {
                  "id": "2",
                  "name": "tomato"
              }
        ]""")
    }
}

Provider

まず、Providerのテストコード生成のために、

mvn spring-cloud-contract:generateTests

を実行すると、以下のようなContractVerifierTest.classが出来上がります。

public class ContractVerifierTest extends ProviderTest {
   @Test
   public void validate_goodsContract() throws Exception {
      // given:
         MockMvcRequestSpecification request = given();
      // when:
         ResponseOptions response = given().spec(request)
               .get("/goods");
      // then:
         assertThat(response.statusCode()).isEqualTo(200);
      // and:
         DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
         assertThatJson(parsedJson).array().contains("['name']").isEqualTo("tomato");
         assertThatJson(parsedJson).array().contains("['id']").isEqualTo("1");
         assertThatJson(parsedJson).array().contains("['id']").isEqualTo("2");
         assertThatJson(parsedJson).array().contains("['name']").isEqualTo("potato");
   }
}

その後、

mvn test

を実行してみると [INFO] BUILD SUCCESS が表示されました。ProviderがContractを保証していることがわかります。

Consumer

次にConsumer側です。先ほど作成したContract、goodsContract.groovyを元にProviderをモック化します。

  1. mvn spring-cloud-contract:convert:WireMock形式に変換(goodsContract.jsonが生成されます)
  2. mvn spring-cloud-contract:run:モックサーバの起動
Tomcat started on port(s): 13186 (http)  Started stub server for project
[<ProjectFolder>\target\stubs:+:stubs] on port 13186

とログ出力されます。(このときのポート番号は起動のたびに変わるみたいです。)

起動したモックサーバを使用するためには、Consumer側の/src/test/resources/配下に設定ファイルで、

stubrunner.stubs.ids={groupId}:{artifactId}:{version}:{classifir}:{port}

を設定すればOKです。

あとはいつもどおりのテストコードを以下のように書いて

package cdc.consumer;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ConsumerApp.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ApplicationTests {
    @Value("${local.server.port}")
    int port;
    TestRestTemplate restTemplate = new TestRestTemplate();
    @Test
    public void contextLoads() {
        assertThat(restTemplate.getForObject("http://localhost:" + port, String.class),
                is("[{\"id\":1,\"name\":\"potato\"},{\"id\":2,\"name\":\"tomato\"}]"));
    }
}

ConsumerもProvider同様、テストの成功を確認できました。

感想

最初にあげた、

  • 呼び出す全てのサービスをMock化
  • 呼び出す全てのサービスをDeploy

に対するデメリットがContractに注目したテストを行うことで見事に解消されている。と感じました。また、Providerに対するテストコードが自動化されていることがメンテナンスのしやすさに相当なメリットがあるように思えます。

今回触ったのはSpring Cloud Contractの中でもほんのごく一部ですので、また機会があれば更に掘り下げた記事を書きたく思っております。

参考(URL順)

  • 株式会社アークシステムの来訪管理・会議室予約システム BRoomHubs
  • 低コスト・短納期で提供するまるごとおまかせZabbix