Spring REST DocsとSpring Cloud Contractを連携して使ってみる

はじめに

Spring REST DocsはSpringのRestFullアプリケーションにおけるWebAPIのインターフェースをドキュメント化する際のサポートをしてくれます。Spring MVC Testをもとにスニペットを自動生成し、Asciidoctorの形式で出力してくれます。
また、Spring Cloud ContractはConsumer Driven Contractのサポートを提供しているプロジェクトです。Spring Cloud Contractに関しては以前にブログを書いたので良ければこちらをご覧ください。
この2つは組み合わせて利用することが可能なようなので、試してみたいと思います。

組み合わせることでなにが変わるのか?

端的に言うと、Spring Cloud Contractで書いていた契約のYamlファイルを書く必要がなくなるみたいです。その分テストを自分で書くことになるのでその面だけで考えると一長一短みたいなところはあるかもしれないです。ただ、全体として考えると、テストを記述することでCDCのサポートを受けれるようになり、かつAPIがヒューマンリーダブルな形でドキュメント化されるようになります。

やってみる

実行環境

実行環境は以下の通り

$ java --version
openjdk 15.0.1 2020-10-20
OpenJDK Runtime Environment (build 15.0.1+9-18)
OpenJDK 64-Bit Server VM (build 15.0.1+9-18, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15.0.1, vendor: Oracle Corporation, runtime: /home/someone/.sdkman/candidates/java/15.0.1-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-52-generic", arch: "amd64", family: "unix"

プロジェクトはSpring Iniializrを使って、以下の設定で作成します

f:id:yuya_hirooka:20201101141103p:plain

また、CDCのConsumer側はSpring Cloud Contractの使い方と変わらないため、Spring Cloud ContractのProducer側のみを作成します。

pomを軽く眺めてみる

作成されたプロジェクトのpomを軽く眺めてみると必要な依存が入っているのといくつかのプラグインが設定されています。

<plugins>
    <plugin>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-contract-maven-plugin</artifactId>
        <version>2.2.4.RELEASE</version>
        <extensions>true</extensions>
        <configuration>
            <testFramework>JUNIT5</testFramework>
        </configuration>
    </plugin>
    <plugin>
        <groupId>org.asciidoctor</groupId>
        <artifactId>asciidoctor-maven-plugin</artifactId>
        <version>1.5.8</version>
        <executions>
            <execution>
                <id>generate-docs</id>
                <phase>prepare-package</phase>
                <goals>
                    <goal>process-asciidoc</goal>
                </goals>
                <configuration>
                    <backend>html</backend>
                    <doctype>book</doctype>
                </configuration>
            </execution>
        </executions>
        <dependencies>
            <dependency>
                <groupId>org.springframework.restdocs</groupId>
                <artifactId>spring-restdocs-asciidoctor</artifactId>
                <version>${spring-restdocs.version}</version>
            </dependency>
        </dependencies>
    </plugin>
    <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
</plugins>

asciidocとspring−cloud-contractのプラグインが入れられているのが確認できます。

Pom等に設定を追加する

2つを連携されるためにいくつか設定を行なう必要があります。

PomにContractのテストをスキップする設定を追加する

前述の通り、REST DocとCloud Contractを連携される場合、Yamlなどの契約を書く必要がありません。つまり、Cloud Contractが自動生成するテスト実行する必要がないので、スキップする設定を追加してやる必要があります。

ドキュメントによると、ユーザープロパティにspring.cloud.contract.verifier.skipをtrueで設定してやることでテストをスキップできるようなので設定を記述します。

<properties>
    <java.version>15</java.version>
    <spring-cloud.version>Hoxton.SR8</spring-cloud.version>
    <spring.cloud.contract.verifier.skip>true</spring.cloud.contract.verifier.skip>
</properties>

Spring Cloud Contract Pluginを削除する(追記)

このブログを投稿した際に以下のようなアドバイスをSpring Cloud Contract のAutherであるMarcin Grzejszczakさんからいただきました。
Thank you for telling me this, Mr. Marcin Grzejszczak!!

Contract とREST Docsを連携させる場合はテストもスタブも自動生成するわけで無いので、このプラグインは必要ないとのことです。なので消してしまって大丈夫です。

Stubの作成のための設定を追加する

StubのJarを作成するためにassembly pluginを入れて設定を追加してやる必要があります。まず、以下のプラグインをPomに追加します。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <id>stub</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>single</goal>
            </goals>
            <inherited>false</inherited>
            <configuration>
                <attach>true</attach>
                <descriptors>./src/assembly/stub.xml</descriptors>
            </configuration>
        </execution>
    </executions>
</plugin>

次にサンプルを参考にstub.xmlを作成し${project_root}/src/assembly/配下に配置します。

<assembly
        xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 https://maven.apache.org/xsd/assembly-1.1.3.xsd">
    <id>stubs</id>
    <formats>
        <format>jar</format>
    </formats>
    <includeBaseDirectory>false</includeBaseDirectory>
    <fileSets>
        <fileSet>
            <directory>src/main/java</directory>
            <outputDirectory>/</outputDirectory>
            <includes>
                <include>**dev/hirooka/model/*.*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>${project.build.directory}/classes</directory>
            <outputDirectory>/</outputDirectory>
            <includes>
                <include>**dev/hirooka/model/*.*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>${project.build.directory}/snippets/stubs</directory>
            <outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/mappings</outputDirectory>
            <includes>
                <include>**/*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>./target/generated-snippets/contracts</directory>
            <outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/contracts</outputDirectory>
            <includes>
                <include>**/*.groovy</include>
            </includes>
        </fileSet>
    </fileSets>
</assembly>

ここで、ポイントは<directory>./target/generated-snippets/contracts</directory>のところで、これは自動生成される契約のDSLを指定してやる必要があります。
デフォルトではtarget配下にcontractディレクトリが作成されそこにgroovyが出力されるのでそこを指定します。

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.2.4.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
        <packageWithBaseClasses>dev.hirooka</packageWithBaseClasses>
    </configuration>
</plugin>

spring-cloud-contract-wiremockの依存を追加する

Spring Cloud Contract Rest Docsのインテグレーションを使うためにはpomに以下の依存を追加する必要があります。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-wiremock</artifactId>
    <scope>test</scope>
</dependency>

こいつを追加することで、Rest Docからスタブを生成することができるようになります。

ベースとなるテストと実装を書く

実装に入る前に先にテストを記述しておきます。 テストはJunit5とMockMVCを使って記述します。Spring REST Docsは他にも、WebTestClientREST Assuredでも利用できます。

@SpringBootTest
class HelloControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    public void setUp(WebApplicationContext context) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @Test
    void HelloControllerのテスト() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.hello").value("world"));
    }
}

ここまでは一般的なテストですね次に実装を書きます。

@RestController
public class HelloController {
    @GetMapping("/")
    public Map<String, String> hello() {
        return Map.of("hello", "world");
    }
}

実装もごくごく一般的なコントローラーです。
テストを実行すると成功します。

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.282 s - in hirooka.dev.cdcanddocs.controller.HelloControllerTest

REST Docsを追加する

ベースとなる実装ができたところでテストにREST Docsの設定を入れていきます。
先程の@BeforeEachで書いたMockMvcの設定とテスト自体を少しいじります。

@SpringBootTest
@ExtendWith(RestDocumentationExtension.class)
class HelloControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    public void setUp(
            WebApplicationContext context,
            RestDocumentationContextProvider provider
    ) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(provider))
                .build();
    }

    @Test
    void HelloControllerのテスト() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.hello").value("world"))
                .andDo(document("index"));
    }
}

僕自身がRestDocsを使うのははじめてなので少しだけ突っ込んでまとめようと思います。

まず、最初に@ExtendWith(RestDocumentationExtension.class)を追加しています。このエクステンションを入れることで、@BforeEachの引数でRestDocumentationContextProviderを受け取ることができます。このクラスはRestDocumentationContextへのアクセスを提供します。
次に@BeforeEachにでMockMvcに設定を追加しています。 MockMvcRestDocumentationdocumentationConfiguration()スタティックメソッドを使ってMockMvcにMockMvc用のRest Docs拡張である MockMvcRestDocumentationConfigurerを登録しています。ここまでで、事前準備は完了です。

次にTestクラスに着目すると、最後のandDo()MockMvcRestDocumentationdocumennt()スタティックメソッドを呼び出しています。このスタティックメソッドは第一引数にドキュメントの識別子と第二引数に可変引数としてSnippetインターフェースの実装を受け取ります。この実装は出力ドキュメントに対して付加情報を追加する際に利用します。

mvn testを実行するとtarget/generated-snippets/index/*.adocにデフォルトでは以下の画像の6種類のasciidocが出力されます。

f:id:yuya_hirooka:20201102123335p:plain

結果の出力はMarkDownなどでも行えるようですが、ここでは深く触れないよ言うにします。

Contractと連携する。

AsciiDocの出力まで終わったところで、Spring Cloud Contractとの連携を行ってみようと思います。
テストコードを以下のように修正します。

@Test
void HelloControllerのテスト() throws Exception {
    mockMvc.perform(get("/"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.hello").value("world"))
            .andDo(document("index", SpringCloudContractRestDocs.dslContract()));
}

document()の引数にSpringCloudContractRestDocs.dslContract()を渡すことで契約のDSLとスタブが自動生成されるようになります。
mvn clean installを実行するとtarget配下が以下のように生成されることがわかります。

f:id:yuya_hirooka:20201102210440p:plain

Consumer側からのStubの利用

いくつか方法がありますが@AutoConfigureStubRunnerを使った方法をこちらにまとめてますのでそちらを参照してください。

リクエストをバリデートするような実装に変えてみる

今までは、リクエストに対して固定値を返すだけのシンプルな実装でしたが、リクエストのパターンによって返すものを変えるような実装に変えてみます。 例えば以下のようなストーリーを想定します。

  • リクエストとしてJsonを受け取り、その値によってOKとNGのステータスを返す

Rest DocsとCloud Contractの連携でリクエストのヴァリファイを行なうスタブを作成する場合、WireMockRestDocsを利用します。

    @Test
    void HelloControllerのOKテスト() throws Exception {
        mockMvc.perform(post("/")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"value\": 10}"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value("OK"))
                .andDo(
                        WireMockRestDocs.verify()
                                .contentType(MediaType.APPLICATION_JSON)
                                .jsonPath("$[?(@.value >= 10)]"))
                .andDo(document("okPattern", SpringCloudContractRestDocs.dslContract()));
    }


    @Test
    void HelloControllerのNGテスト() throws Exception {
        mockMvc.perform(post("/")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"value\": 9}"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value("NG"))
                .andDo(
                        WireMockRestDocs.verify()
                                .contentType(MediaType.APPLICATION_JSON)
                                .jsonPath("$[?(@.value < 10)]"))
                .andDo(document("ngPattern", SpringCloudContractRestDocs.dslContract()));
    }

同じくmvn clean installをすればstubが生成されます。

参考資料