L is B BLOG

株式会社L is Bの社員ブログです。会社の取り組みや技術ブログを発信しています。

Spring ではないプロジェクトに Spring REST Docs を使う

みなさん、こんにちは。 direct のサーバー開発担当の持田です。

f:id:mike_neck:20180615180203p:plain

本日は Spring を使わない、 Spring REST Docs の話をしようと思います。

Spring REST Docs は spring-core に依存しているため、実質的には Spring を使っているのですが、 ここではプロダクションコードに Spring を使っていないプロジェクトに Spring REST Docs を使って テストとドキュメンテーションを行うという意味で「 Spring を使わない」という言葉を使っています。


Spring REST Docs とは?

Spring REST Docs はAPIのテストとあわせて用いるドキュメント生成ツールです。 Spring REST Docs では、 Mock MVC または WebTestClient(Spring WebFlux 製) REST Assured をクライアントとして使い、ドキュメント対象のサービスにリクエストを投げます。 そして、キャプチャーされたリクエストとレスポンスおよびテストコードに書かれたフィールド定義をスニペットファイル(asciidoc形式)を出力します。 これらのスニペットを手書きのドキュメントに組み込むことで、サーバーAPIの仕様書を生成します。

ところで、 REST Assured に Spring REST Docs を組み込む際のコードは、公式ドキュメントを参照すると、次のようになります。

private RequestSpecification spec;

@BeforeEach
public void setUp(RestDocumentationContextProvider restDocumentation) {
  this.spec = new RequestSpecBuilder()
      .addFilter(documentationConfiguration(restDocumentation)) 
      .build();
}

ここには、 @Autowired は出てきませんし、 RequestSpecification 自体も RequestSpecBuildernew して作っているだけです。 さらに、 REST Assured を使う際の JUnit5 拡張である RestDocumentationExtension も JUnit5 の拡張である以上、デフォルトコンストラクターによって 生成できなければなりません。したがって、 REST Assured を利用したテストの場合、 Spring のビーンをインジェクションしなくてもテストが書けるのです。 つまり、 プロダクション部分が Spring でなくても Spring REST Assured を使えるということです!


使うに至った経緯

6月リリースにて内部的に使うREST APIをいくつか作成しました。 当初ドキュメンティングには Swagger(現Open API) を利用しておりましたが、 ドキュメントを書いた際に想定していなかった事情によっていくつかのAPIで仕様が変わっており、 実装とドキュメントの乖離が発生してしまいました。 しかし Swagger を維持するために JAX-RS のコードにアノテーションを増やすのもためらわれておりました。 そこで視点を変えて、 Spring REST Docs のような動くコードからドキュメントを生成できるツールのほうがよいのではないかと考え、 採用してみることにしました。


実用例

サービス用のプロジェクトの中に別のサブプロジェクトを作ります。

plugins {
    id 'java'
    id 'org.asciidoctor.convert' version '1.5.3' // (1)
}
ext {
    junitVersion = '5.2.0'
    restDocsVersion = '2.0.1.RELEASE'
    snippetsDir = file('build/generated-snippets')
}
dependencies {
    testCompile "org.junit.jupiter:junit-jupiter-api:$junitVersion"
    testCompile "org.junit.jupiter:junit-jupiter-params:$junitVersion"
    testRuntime "org.junit.jupiter:junit-jupiter-engine:$junitVersion"

    // (2)

    asciidoctor "org.springframework.restdocs:spring-restdocs-asciidoctor:$restDocsVersion"

    // (3)
    testCompile "org.springframework.restdocs:spring-restdocs-restassured:$restDocsVersion"
    testCompile 'io.rest-assured:rest-assured:3.1.0'

    // その他の依存ライブラリー
}

test {
    useJUnitPlatform()
    exclude '**/*$*'
    outputs.dir snippetsDir
}

asciidoctor { // (4)
    inputs.dir snippetsDir
    dependsOn test
}
  1. org.asciidoctor.convert プラグイン1.5.3 よりも後のものを使うと、 1.5.6 にて混入したデグレードによって正しく出力されないため、 1.5.3 にバージョン固定します
  2. Spring 関連の依存ライブラリーは使いません。また、 Gradle Spring Boot プラグイン等も用いていません。
  3. REST Assured を使う場合の依存ライブラリーです。 Mock MVC などを使う場合は別のものに変更してください。
  4. 最終的なドキュメントの生成はテストが作成するスニペットに依存するので、 asciidoctor タスクを test に依存させます。

次にテストコードのうち、テスト以外の部分のコードです。

@ExtendWith({
  GuiceExtension.class,   // (1)
  RestDocumentationExtension.class, // (2)
  EncoderConfigurationCallback.class // (3)
})
@IncludeModule(IntegrationTestModule.class)
class TalksCreateApiTest {

  private static final String TALKS_CREATE = "/1/talks/create";

  private static String documentLocation(final String testName) {
    return String.format("talks/create/%s", testName);
  }

  @Inject
  @RegisterExtension
  @SuppressWarnings("unused")
  DatabaseCleanerExtension databaseCleanerExtension; // (4)

  @Inject
  @Named("endpoint")
  private URI endpoint;

  private RequestSpecification spec;

  @BeforeEach
  void setUp(final RestDocumentationContextProvider restDocumentationContextProvider) {
    this.spec = // (5)
        new RequestSpecBuilder()
            .addFilter(
                documentationConfiguration(restDocumentationContextProvider)
                    .operationPreprocessors()
                    .withResponseDefaults(prettyPrint()))
            .setBaseUri(endpoint)
            .build();
    // その他のセットアップコード
  }
}
  1. サーバーのコードは guice を使っているので、 JUnit5 用の GuiceExtension を使っています。
  2. Spring REST Docs を JUnit5 で使う場合は RestDocumentationExtension を使います。
  3. EncoderConfigurationCallback は REST Assured 用のエンコーディング設定を行う自作の拡張です。
  4. DatabaseCleanerExtension はデータベースの初期化を行う自作の拡張で、 guice にオブジェクト管理されています。これに RegisterExtension アノテーションをつけることで、拡張として使えるようにしています。
  5. RestDocumentationExtension を使うと、パラメーターに RestDocumentationContextProvider を取れるようになります。これを用いて RequestSpecification オブジェクトを作ります。

次に実際にREST Assured を使ったテストコードです。

class TalksCreateApiTest {
  @Test
  void successCase() {
    given(spec)
        .filter(
            document(
                documentLocation("success"), //(1)
                requestHeaders(headerWithName("Authorization").description("アクセストークン")), // (2)
                requestParameters( // (3)
                    parameterWithName("talkName").description("トーク名"),
                    parameterWithName("ruleId").description("このトークに適用するルールID")),
                responseFields( // (4)
                    fieldWithPath("talk_id").description("作成されたトークのID"),
                    fieldWithPath("talk_id_str").description("作成されたトークIDの文字列表記"),
                    fieldWithPath("talk_name").description("作成されたトーク名"),
                    fieldWithPath("created").description("トークが作成された時刻のISO8601表記"))))
    // (5)
        .header("Authorization", accessToken.bearerToken())
        .contentType("application/x-www-form-urlencoded")
        .formParams(formData("テスト用トーク", 2))
    // (6)
        .when()
        .post(TALKS_CREATE)
        .then()
    // (7)
        .assertThat()
        .statusCode(HttpStatus.CREATED.value())
        .body("talk_name", is("テスト用トーク"))
        .body("talk_id", is(instanceOf(Long.class)))
        .body("talk_id_str", matches("[0-9]+"))
        .body("created", ISO8601_STRING);
  }
}
  1. ドキュメントの場所を指定します。先程表示したメソッドの内容も合わせると、サブプロジェクト内の build/generated-snippets/talks/create/success/ 以下にファイルが出力されます。
  2. ヘッダーの詳細説明用の記述を行います。このAPIはアクセストークンが必須なAPIなのでそれを記述しています。
  3. フォームの各項目の詳細説明用の記述を行います。
  4. レスポンスのjsonに含まれるフィールドの詳細説明を記述します。
  5. リクエストを組み立てます。
  6. このAPIPOST ですので、post でリクエストします。
  7. レスポンスに対するテストを書きます。

また、成功系以外のテストでは、レスポンスだけ出力したいので、次のようなドキュメントの記述を行います。

class TalksCreateApiTest {
  @Test
  void noForm() {
    given(spec)
        .filter(document(documentLocation("no-form"))
            .document( // (1)
                httpResponse(),
                responseFields(
                    fieldWithPath("message").description("エラーメッセージ"),
                    fieldWithPath("code").description("エラーコード"),
                    fieldWithPath("errors.*.fields").description("エラーのあった入力項目"))))
        .auth()
        .oauth2(accessToken.getValue())
        .when()
        .post(TALKS_CREATE)
        .then()
        .assertThat()
        .statusCode(HttpStatus.BAD_REQUEST.value())
        .body("message", is(notNullValue()))
        .body("code", is(instanceOf(Integer.class)))
        .body("$.errors.length", is(2))
        .body("$.errors[0].fields", is("ruleId"))
        .body("$.errors[1].fields", is("talkName"));
  }
}
  1. document メソッドの後に document(Snippet...) を呼び出すことで、生成されるスニペットを絞り込みます。

次にテストによって生成されるスニペットをまとめるドキュメンを src/docs/asciidoc 以下のディレクトリーに作成します。

= `talks/create` トーク作成API

== リクエスト

=== curl

include::{snippets}/talks/create/success/curl-request.adoc[]

=== HTTPie

include::{snippets}/talks/create/success/httpie-request.adoc[]

---

=== ヘッダー

include::{snippets}/talks/create/success/request-headers.adoc[]

=== パラメーター

include::{snippets}/talks/create/success/request-parameters.adoc[]

== レスポンス

=== 成功時

include::{snippets}/talks/create/success/response-body.adoc[]

==== レスポンスフィールド

include::{snippets}/talks/create/success/response-fields.adoc[]

テストを実行してドキュメントを作成

上記のテストを書き終えたら、次のコマンドによってテストを流します。

$ ./gradlew rest-api-test:asciidoc -Dtest.single=TalksCreateApiTest
  • asciidoctest に依存しているので、このタスクだけでテストも実行されます。
  • テストを作っている最中は -Dtest.single=[クラス名] で対象のテストを絞って実行すると、短い時間でドキュメントのフィードバックが得られます。

これだけで、見栄えの良い動く(動かないと生成されない)ドキュメントの完成です!


今後の展開

Swagger はAPIの設計段階で型が共有できたり、スタートアップする段階では便利なツールだと思いましたが、 いざメンテナンスをしようと思うと考慮する箇所がけっこうあって大変な印象をもちました。 それとは別に Spring REST Docs の場合、型の名前などは共有されないため好きな名前をサーバー/クライアントで使えるし、 ドキュメントのとおりに動くものができるし、 暗黙の了解といった歴史の過去を紐解いていく必要もないため、 メンテナンスに入るAPIで適用しやすい印象を持ちました。

スニペットをまとめるドキュメントは手書きなのですが、それらもなるべく自動で生成できるように Extension クラスを作ろうと考えています。


L is B では

ただいま Java を書きたいエンジニア、 AWS を触りたいエンジニアを募集しております。 こちら よりご応募ください。皆さんのご応募お待ちしております!!!