Spring Cloud Contractを触ってみようと思ったきっかけは、会社での SwaggerでAPI仕様書を書く
-> レビュー
-> 実装
という開発フロー問題を感じているからです。
このフローには 1.実装とAPI仕様書で乖離してしまう恐れがある 、 2.いちいちAPI仕様書を書くのがめんどくさい 、 3.実装が仕様を満たしている保証はできない という問題があります。
そこで、実装とAPI仕様書が乖離しないいい感じのライブラリを探してたら、「Spring Fox」と「Spring REST Docs」が見つかりました。しかし、どちらもコードからAPI仕様書を生成してくれるライブラリなのですが、微妙だなと感じました。 Spring Foxは、公式でないしプロダクトコードに手を加えないといけないです。Spring REST Docsは理想に近かったのですが、TDD向きで今から始めるには辛いかなと。 このような相談を知り合いのエンジニアに話したら、「Spring Cloud Contract」があるよと教えてくれました。
Spring Cloud Contractとは
Spring Cloud Contractは、CDC(Consumer Driven Contracts)をサポートするためのプロジェクトでマイクロサービス化されたアプリケーションに嬉しいプロジェクトです。
Consumer Driven Contracts testingとは
Consumer Driven Contracts(CDC) testingは主にマイクロサービスに役立つテスト手法です。 Consumer(サービスを使う側)が定義したContract(契約)をProvider(サービスを提供する側)が守らなければなりません。
サンプルアプリケーション
ソースコードはGitHubに置いてあります。ProducerConsumer
サンプルの内容
- ProducerとConsumerを実装
- Spring Cloud Contractを使ってCDC testingできるようにする
サンプルは、Java13、Spring Boot2.2.0で作っています。
Producer
Controllerの実装
BrandController.java
@RestController @RequestMapping("/brands") public class BrandController { @GetMapping public BrandListDto list() { Brand stof = new Brand(); stof.setName("STOF"); stof.setDesigner("Tanita"); BrandListDto listDto = new BrandListDto(); listDto.setBrands(List.of(stof)); return listDto; } }
Spring Cloud Contractを依存関係に追加
pom.xml
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract-maven-plugin.verison}</version> <extensions>true</extensions> <configuration> <!-- 生成するテストがextendsするクラス --> <baseClassForTests> com.b1a9idps.springcloudcontractsample.producer.TestBase </baseClassForTests> <!-- JUnit5で利用できるように(デフォルトはJUnit4) --> <testFramework>JUNIT5</testFramework> </configuration> </plugin> </plugins> </build>
TestBase.java
@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) // DIコンテナを破棄するタイミングをコントロールする @DirtiesContext public abstract class TestBase { @BeforeEach public void setup(WebApplicationContext context) { RestAssuredMockMvc.mockMvc( MockMvcBuilders.webAppContextSetup(context).build()); } }
Contractを追加
GroovyかYAMLで記述します。デフォルトのパスは、 $rootDir/src/test/resources/contracts
です。
brand.yml
request: method: GET url: /brands headers: Content-Type: application/json response: status: 200 body: brands: - name: "STOF" designer:: "Tanita" headers: Content-Type: application/json
期待するリクエストとレスポンスをそれぞれ記述します。bodyを別でjsonファイルを定義することもできます。
brand.yml
request: method: GET url: /brands headers: Content-Type: application/json response: status: 200 bodyFromFile: brand_response.json headers: Content-Type: application/json
テストクラスを生成する
./mvnw clean install
を実行することでテストクラスが生成して、スタブを作ってローカルレポジトリにインストールします。
... [INFO] Generating server tests source code for Spring Cloud Contract Verifier contract verification [INFO] Will use contracts provided in the folder [/Users/uchitate/study/producer/src/test/resources/contracts] [INFO] Directory with contract is present at [/Users/uchitate/study/producer/src/test/resources/contracts] [INFO] Test Source directory: /Users/uchitate/study/producer/target/generated-test-sources/contracts added. [INFO] Using [com.b1a9idps.springcloudcontractsample.producer.TestBase] as base class for test classes, [null] as base package for tests, [null] as package with base classes, base class mappings [] [INFO] Creating new class file [/Users/uchitate/study/producer/target/generated-test-sources/contracts/com/b1a9idps/springcloudcontractsample/producer/ContractVerifierTest.java] [INFO] Generated 1 test classes. [INFO] [INFO] --- spring-cloud-contract-maven-plugin:2.1.3.RELEASE:convert (default-convert) @ producer --- [INFO] Will use contracts provided in the folder [/Users/uchitate/study/producer/src/test/resources/contracts] [INFO] Copying Spring Cloud Contract Verifier contracts to [/Users/uchitate/study/producer/target/stubs/META-INF/com.b1a9idps.spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/contracts]. Only files matching [.*] pattern will end up in the final JAR with stubs. [INFO] Using 'UTF-8' encoding to copy filtered resources. [INFO] Copying 2 resources [INFO] Converting from Spring Cloud Contract Verifier contracts to WireMock stubs mappings [INFO] Spring Cloud Contract Verifier contracts directory: /Users/uchitate/study/producer/src/test/resources/contracts [INFO] Stub Server stubs mappings directory: /Users/uchitate/study/producer/target/stubs/META-INF/com.b1a9idps.spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/mappings [INFO] Creating new stub [/Users/uchitate/study/producer/target/stubs/META-INF/com.b1a9idps.spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/mappings/brand.json] [INFO] [INFO] --- maven-resources-plugin:3.1.0:testResources (default-testResources) @ producer --- [INFO] Using 'UTF-8' encoding to copy filtered resources. [INFO] Copying 2 resources [INFO] skip non existing resourceDirectory /Users/uchitate/study/producer/target/generated-test-resources/contracts [INFO] [INFO] --- maven-compiler-plugin:3.8.1:testCompile (default-testCompile) @ producer --- [INFO] Changes detected - recompiling the module! [INFO] Compiling 2 source files to /Users/uchitate/study/producer/target/test-classes [INFO] [INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ producer --- ... // テスト実行 ... [INFO] --- spring-cloud-contract-maven-plugin:2.1.3.RELEASE:generateStubs (default-generateStubs) @ producer --- [INFO] Files matching this pattern will be excluded from stubs generation [] [INFO] Building jar: /Users/uchitate/study/producer/target/producer-0.0.1-SNAPSHOT-stubs.jar [INFO] [INFO] --- maven-jar-plugin:3.1.2:jar (default-jar) @ producer --- [INFO] Building jar: /Users/uchitate/study/producer/target/producer-0.0.1-SNAPSHOT.jar [INFO] [INFO] --- spring-boot-maven-plugin:2.2.0.RELEASE:repackage (repackage) @ producer --- [INFO] Replacing main artifact with repackaged archive [INFO] [INFO] --- maven-install-plugin:2.5.2:install (default-install) @ producer --- [INFO] Installing /Users/uchitate/study/producer/target/producer-0.0.1-SNAPSHOT.jar to /Users/uchitate/.m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT.jar [INFO] Installing /Users/uchitate/study/producer/pom.xml to /Users/uchitate/.m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT.pom [INFO] Installing /Users/uchitate/study/producer/target/producer-0.0.1-SNAPSHOT-stubs.jar to /Users/uchitate/.m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT-stubs.jar
生成されたテストクラス(ContractVerifierTest.java)
public class ContractVerifierTest extends TestBase { @Test public void validate_brand() throws Exception { // given: MockMvcRequestSpecification request = given() .header("Content-Type", "application/json"); // when: ResponseOptions response = given().spec(request) .get("/brands"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).isEqualTo("application/json"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).array("['brands']").contains("['designer']").isEqualTo("Tanita"); assertThatJson(parsedJson).array("['brands']").contains("['name']").isEqualTo("STOF"); } }
Consumer
BrandServiceの実装
BrandServiceImpl.java
@Service public class BrandServiceImpl implements BrandService { private final RestTemplate restTemplate; private final URI producerUrl; public BrandServiceImpl(RestTemplateBuilder restTemplateBuilder, @Value("${producer.url}") String producerUrl) { this.restTemplate = restTemplateBuilder.build(); this.producerUrl = UriComponentsBuilder.fromHttpUrl(producerUrl).build().toUri(); } @Override public BrandListDto list() { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_VALUE); ResponseEntity<BrandListDto> responseEntity = restTemplate.exchange( producerUrl, HttpMethod.GET, new HttpEntity<>(httpHeaders), new ParameterizedTypeReference<>() {}); return responseEntity.getBody(); } }
Spring Cloud Contractを依存関係に追加
Producer側とほぼ同じなので説明は省略します。
pom.xml
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract-maven-plugin.verison}</version> <configuration> <testFramework>JUNIT5</testFramework> </configuration> </plugin> </plugins> </build>
テストを書く
/test/resources/application.yaml
producer: url: http://localhost:8080/brands
BrandServiceImplTest.java
// StubRunnnerExtension.classを登録する @ExtendWith({SpringExtension.class, StubRunnerExtension.class}) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureStubRunner(ids = {"com.b1a9idps.spring-cloud-contract-sample:producer:+:stubs:8080"}, stubsMode = StubsMode.LOCAL) class BrandServiceImplTest { @Autowired private BrandService brandService; @Test void list() { Assertions.assertThat(brandService.list().getBrands()) .extracting(Brand::getName, Brand::getDesigner) .containsExactly(Tuple.tuple("STOF", "Tanita")); } }
@AutoConfigureStubRunner
を付与してスタブ生成の対象を指定します。idsに指定する値は、 [groupId]:artifactId[:version][:classifier][:port]
です。.m2レポジトリに .m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT-stubs.jar
とjarがインストールされているので、 com.b1a9idps.spring-cloud-contract-sample:producer:+:stubs:8080
と指定します。バージョンを +
で指定すると最新バージョンを設定します。
テストを実行
2019-11-19 00:07:15.872 INFO 24517 --- [ main] o.s.c.c.s.AetherStubDownloaderBuilder : Will download stubs and contracts via Aether 2019-11-19 00:07:15.874 INFO 24517 --- [ 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. 2019-11-19 00:07:15.984 INFO 24517 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Desired version is [+] - will try to resolve the latest version 2019-11-19 00:07:15.997 INFO 24517 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved version is [0.0.1-SNAPSHOT] 2019-11-19 00:07:16.007 INFO 24517 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved artifact [com.b1a9idps.spring-cloud-contract-sample:producer:jar:stubs:0.0.1-SNAPSHOT] to /Users/uchitate/.m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT-stubs.jar 2019-11-19 00:07:16.010 INFO 24517 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacking stub from JAR [URI: file:/Users/uchitate/.m2/repository/com/b1a9idps/spring-cloud-contract-sample/producer/0.0.1-SNAPSHOT/producer-0.0.1-SNAPSHOT-stubs.jar] 2019-11-19 00:07:16.016 INFO 24517 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacked file to [/var/folders/0z/n7t2zxm566zfmzbtg7qdc6_80000gp/T/contracts17319340210365511943] 2019-11-19 00:07:16.705 INFO 24517 --- [ main] wiremock.org.eclipse.jetty.util.log : Logging initialized @4424ms 2019-11-19 00:07:16.770 INFO 24517 --- [ main] w.org.eclipse.jetty.server.Server : jetty-9.2.z-SNAPSHOT 2019-11-19 00:07:16.784 INFO 24517 --- [ main] w.o.e.j.server.handler.ContextHandler : Started w.o.e.j.s.ServletContextHandler@5c60c08{/__admin,null,AVAILABLE} 2019-11-19 00:07:16.785 INFO 24517 --- [ main] w.o.e.j.server.handler.ContextHandler : Started w.o.e.j.s.ServletContextHandler@212e39ee{/,null,AVAILABLE} 2019-11-19 00:07:16.810 INFO 24517 --- [ main] w.o.e.j.s.NetworkTrafficServerConnector : Started NetworkTrafficServerConnector@173e960b{HTTP/1.1}{0.0.0.0:8080} 2019-11-19 00:07:16.811 INFO 24517 --- [ main] w.org.eclipse.jetty.server.Server : Started @4532ms 2019-11-19 00:07:16.812 INFO 24517 --- [ main] o.s.c.contract.stubrunner.StubServer : Started stub server for project [com.b1a9idps.spring-cloud-contract-sample:producer:0.0.1-SNAPSHOT:stubs] on port 8080 2019-11-19 00:07:17.082 INFO 24517 --- [tp2142521143-31] /__admin : RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.AdminRequestHandler. Normalized mapped under returned 'null' 2019-11-19 00:07:17.106 INFO 24517 --- [tp2142521143-31] WireMock : Admin request received: 127.0.0.1 - POST /mappings Connection: [keep-alive] User-Agent: [Apache-HttpClient/4.5.5 (Java/13)] Host: [localhost:8080] Content-Length: [225] Content-Type: [text/plain; charset=UTF-8] { "id" : "a18b18d9-bf53-478a-be02-c4b05d417911", "request" : { "url" : "/ping", "method" : "GET" }, "response" : { "status" : 200, "body" : "OK" }, "uuid" : "a18b18d9-bf53-478a-be02-c4b05d417911" } 2019-11-19 00:07:17.180 INFO 24517 --- [tp2142521143-32] WireMock : Admin request received: 127.0.0.1 - POST /mappings Connection: [keep-alive] User-Agent: [Apache-HttpClient/4.5.5 (Java/13)] Host: [localhost:8080] Content-Length: [227] Content-Type: [text/plain; charset=UTF-8] { "id" : "d4c408f0-344e-4994-9118-f3844404a3fc", "request" : { "url" : "/health", "method" : "GET" }, "response" : { "status" : 200, "body" : "OK" }, "uuid" : "d4c408f0-344e-4994-9118-f3844404a3fc" } 2019-11-19 00:07:17.246 INFO 24517 --- [tp2142521143-33] WireMock : Admin request received: 127.0.0.1 - POST /mappings Connection: [keep-alive] User-Agent: [Apache-HttpClient/4.5.5 (Java/13)] Host: [localhost:8080] Content-Length: [493] Content-Type: [text/plain; charset=UTF-8] { "id" : "3e1ac4f8-4565-42b5-946a-e136e1822941", "request" : { "url" : "/brands", "method" : "GET", "headers" : { "Content-Type" : { "equalTo" : "application/json" } } }, "response" : { "status" : 200, "body" : "{\"brands\":[{\"name\":\"STOF\",\"designer\":\"Tanita\"}]}", "headers" : { "Content-Type" : "application/json" }, "transformers" : [ "response-template" ] }, "uuid" : "3e1ac4f8-4565-42b5-946a-e136e1822941" } 2019-11-19 00:07:17.313 INFO 24517 --- [ main] o.s.c.c.stubrunner.StubRunnerExecutor : All stubs are now running RunningStubs [namesAndPorts={com.b1a9idps.spring-cloud-contract-sample:producer:0.0.1-SNAPSHOT:stubs=8080}] 2019-11-19 00:07:17.358 INFO 24517 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 53672 (http) with context path '' 2019-11-19 00:07:17.362 INFO 24517 --- [ main] c.b.s.c.s.impl.BrandServiceImplTest : Started BrandServiceImplTest in 3.981 seconds (JVM running for 5.083) 2019-11-19 00:07:17.365 WARN 24517 --- [ main] o.s.c.c.stubrunner.StubRunnerFactory : No stubs to download have been passed. Most likely you have forgotten to pass them either via annotation or a property 2019-11-19 00:07:17.735 INFO 24517 --- [tp2142521143-31] / : RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.StubRequestHandler. Normalized mapped under returned 'null' 2019-11-19 00:07:17.801 INFO 24517 --- [tp2142521143-31] WireMock : Request received: 127.0.0.1 - GET /brands Connection: [keep-alive] User-Agent: [Apache-HttpClient/4.5.9 (Java/13)] Host: [localhost:8080] Accept-Encoding: [gzip,deflate] Accept: [application/json, application/*+json] Content-Type: [application/json] Matched response definition: { "status" : 200, "body" : "{\"brands\":[{\"name\":\"STOF\",\"designer\":\"Tanita\"}]}", "headers" : { "Content-Type" : "application/json" }, "transformers" : [ "response-template" ] } Response: HTTP/1.1 200 Content-Type: [application/json] Matched-Stub-Id: [3e1ac4f8-4565-42b5-946a-e136e1822941] 2019-11-19 00:07:17.913 WARN 24517 --- [ main] .StubRunnerWireMockTestExecutionListener : You've used fixed ports for WireMock setup - will mark context as dirty. Please use random ports, as much as possible. Your tests will be faster and more reliable and this warning will go away 2019-11-19 00:07:17.923 INFO 24517 --- [ main] w.o.e.j.s.NetworkTrafficServerConnector : Stopped NetworkTrafficServerConnector@173e960b{HTTP/1.1}{0.0.0.0:8080} 2019-11-19 00:07:17.924 INFO 24517 --- [ main] w.o.e.j.server.handler.ContextHandler : Stopped w.o.e.j.s.ServletContextHandler@212e39ee{/,null,UNAVAILABLE} 2019-11-19 00:07:17.924 INFO 24517 --- [ main] w.o.e.j.server.handler.ContextHandler : Stopped w.o.e.j.s.ServletContextHandler@5c60c08{/__admin,null,UNAVAILABLE} 2019-11-19 00:07:17.925 WARN 24517 --- [ main] w.o.e.j.util.thread.QueuedThreadPool : qtp2142521143{STOPPING,8<=8<=10,i=3,q=6} Couldn't stop Thread[qtp2142521143-28,5,] 2019-11-19 00:07:17.925 WARN 24517 --- [ main] w.o.e.j.util.thread.QueuedThreadPool : qtp2142521143{STOPPING,8<=8<=10,i=0,q=4} Couldn't stop Thread[qtp2142521143-29,5,] 2019-11-19 00:07:17.926 INFO 24517 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Shutting down ExecutorService 'applicationTaskExecutor' Class transformation time: 0.040030372s for 10526 classes or 3.8029994299828998E-6s per class Process finished with exit code 0
これがテストを実行時に吐き出されるログです。テストが実行されるまでに「1. ローカルレポジトリからstubのjarをインストール」「2. 8080番ポートでモックサーバを起動」が行われています。
まとめ
Producer側では、Contractに基づいたテストが生成され、Consumer側で利用するためのスタブを生成してローカルレポジトリにインストールします。Consumer側では、生成されたスタブを使ってテストを実行します。 Spring Cloud Contractを使えば、Consumerが定義したContractをProviderが守っていることが担保できます。また、Consumerも実サービスに近いテストを行うことができます。とてもよいですね!!!
長くなりすぎたので、別記事でSpring REST Docsとの連携は書きます!