🫖

Kotlin(Spring Boot)+ MockMVC + DatabaseRider で簡単 API 統合テスト

2022/11/11に公開

概要

本記事では、サーバーサイド Kotlin(Spring Boot)で作成した Web API に対して、API 統合テストを実践する方法を紹介します。MockMVC と DatabseRider を組み合わせることで、簡単に API 統合テストが実現できできることを実感してもらいます。

記事の最初に、本記事で利用するサンプルコードと主要なライブラリである MockMVC、DatabaseRider を紹介します。その後、具体的にどのようなコードになるのか解説します。

前提

本記事で使用する API とサンプルコード

本記事では以下の API で統合テストを実装します。

メソッド パス
get /api/customers
post /api/customers

詳細は以下の openapi.yml にまとめました。

openapi.yaml。
openapi: "3.0.2"
info:
  title: API Title
  version: "1.0"
  license:
    name: MIT License
    url: https://opensource.org/licenses/MIT

servers:
  - url: /api
paths:
  /customers/{id}:
    get:
      tags:
        - Customer
      summary: コメントの単一取得
      description: コメント ID に該当するコメントを取得します
      operationId: GetCustomerById
      parameters:
        - name: id
          in: path
          description: コメント ID
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SingleCustomerReponse"
        "404":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericErrorModel"
  /customers:
    post:
      tags:
        - Customer
      summary: カスタマーの登録
      description: カスタマーを登録します
      operationId: RegisterCustomer
      requestBody:
        description: 登録したいカスタマーの情報
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewCustomerRequest"
        required: true
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SingleCustomerReponse"
      x-codegen-request-body-name: customer
components:
  schemas:
    SingleCustomerReponse:
      required:
        - customer
      type: object
      properties:
        customer:
          $ref: "#/components/schemas/Customer"
    Customer:
      required:
        - id
        - firstName
        - lastName
      type: object
      properties:
        id:
          type: integer
        firstName:
          type: string
        lastName:
          type: string
    NewCustomer:
      required:
        - body
        - firstName
        - lastName
      type: object
      properties:
        firstName:
          type: string
        lastName:
          type: string
    NewCustomerRequest:
      required:
        - customer
      type: object
      properties:
        customer:
          $ref: "#/components/schemas/NewCustomer"
    GenericErrorModel:
      required:
        - errors
      type: object
      properties:
        errors:
          required:
            - body
          type: object
          properties:
            body:
              type: array
              items:
                type: string

swagger UI に挙げています。

https://msksgm.github.io/kotlin-spring-mockmvc-with-databaserider/swagger/

以下に本記事で利用する API のサンプルコードを載せました。

https://github.com/Msksgm/kotlin-spring-mockmvc-with-databaserider

MockMVC

MockMVC とは、Spring Test に含まれるテストフレームワークの 1 つです。
実装したサーバに対して、手動でリクエストを送ることなく、API テストが可能です。

MockMVC は初期化のために、@AutoConfigureMockMvc と@SpringBootTest が必要です。

MockMVC の実践例
class SampleTest {
    @SpringBootTest
    @AutoConfigureMockMvc
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    class Get @Autowired constructor(
      val mockMvc: MockMvc
    ) {
        @BeforeAll
        fun reset() = // テストのたびに実行される処理

        @Test
        fun `テスト`() {

        }
    }
}

以下のように MockMvc を利用することで、リクエストパラメータを送ります。
レスポンスステータスとレスポンスボディも評価できます。

MockMVC によるリクエストパラメータの送り方
val response = mockMvc.perform(
    MockMvcRequestBuilders
        .get("/path/to/api")
        .contentType(MediaType.APPLICATION_JSON)
).andReturn().response
val actualStatus = response.status
val actualResponseBody = response.contentAsString

https://spring.pleiades.io/spring-framework/docs/current/reference/html/testing.html#spring-mvc-test-framework

https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#spring-mvc-test-framework

DatabaseRider

DatabaseRider とは、アノテーションによって、DB テストを簡潔に記述できる Java のライブラリです。
以下のように、アノテーションによって、入力データの挿入、テスト後の DB の期待値、テスト完了後のテーブルのファイルへの書き出し、が可能です。
つまり、テスト前に DB に対して挿入されるデータを指定し、テスト後に DB があるべきデータを比較可能です。
API 統合テストでレスポンス以外に DB の状態を保証できるのは、API テストと非常に相性が良く採用しました。

DatabaseRider のアノテーション
@Test
@DBRider
@DataSet("path/to/input/data.{yml,csv,cml}") // 入力データのファイルを指定
@ExpectedDataSet(
    value = ["path/to/expected/data.{yml,csv,cml}"], // 期待値のデータのファイルを指定
    orderBy = ["id"],
    ignoreCols = ["createdAt", "updatedAt"], // メタデータを無視する
)
// NOTE: 一度データを書き出したら、コメントアウトする
@ExportDataSet(
    format = DataSetFormat.YML, // テスト完了後にデータを書き出す形式を指定
    outputName = "path/to/export/data.{yml,csv,cml}", // 書き出すファイル先を指定
    includeTables = ["customer"] // 含めるカラムを指定
)
fun `テスト`() {
    // テストを記述
}

Database Rider について詳細は以下の記事を参考にしてください。

https://zenn.dev/msksgm/articles/20221014-kotlin-dbtest-with-database-rider

解説

これから、MockMVC と DatabaseRider を組み合わせることで、API 統合テストを実践する方法を紹介します。
GET リクエストと POST リクエストの 2 つのパターンで解説します。
大まかな手順と MockMVC と DatabseRider の役割は以下です。

手順 ライブラリ 役割
1 DatabaseRider 事前条件のデータセットを挿入
2 MockMVC API 操作とレスポンスの評価
3 DatabaseRider 事後条件のデータセットを確認

Get リクエスト

DatabaseRider と MockMVC を組み合わせたソースコード以下になります。
@DataSetアノテーションにより、事前条件のデータセットを指定します。
テストでは、Gherkin 記法にならって、given(前提条件)、when(操作)、Then(期待する結果)を API に合わせて記述します。具体的には以下の表にまとめられます。

Gherkin Steps 内容
given 存在するユーザー ID
when get /api/customers/id を実行
then ステータスコードとレスポンスボディが一致する

そして、@ExpectedDataSet には事後条件のデータセットを確認します。ファイルと一致していない場合、テストが失敗します。今回は、テスト前後でデータが変わっていないことを確認しました。
ここで利用した common.yml は以下です。

common.yml
datasets/yml/given/common.yml
customer:
  - id: 1
    first_name: "Alice"
    last_name: "Sample1"
  - id: 2
    first_name: "Bob"
    last_name: "Sample2"

    @SpringBootTest
    @AutoConfigureMockMvc
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @DBRider
    class GetCustomerById @Autowired constructor(
        val mockMvc: MockMvc
    ) {
        @Test
        @DataSet("datasets/yml/given/common.yml")
        @ExpectedDataSet(
            value = ["datasets/yml/given/common.yml"],
            orderBy = ["id"]
        )
        fun `正常系 Customer の単一取得`() {
            /**
             * given:
             * - 存在するユーザー ID
             */
            val id = 1

            /**
             * when:
             */
            val response = mockMvc.perform(
                MockMvcRequestBuilders
                    .get("/api/customers/$id")
                    .contentType(MediaType.APPLICATION_JSON)
            ).andReturn().response
            val actualStatus = response.status
            val actualResponseBody = response.contentAsString

            /**
             * then:
             * - ステータスコードが一致する
             * - レスポンスボディが一致する
             */
            val expectedStatus = HttpStatus.OK.value()
            val expectedResponseBody =
                """
                    {
                      "customer": {
                        id: 1,
                        firstName: "Alice",
                        lastName: "Sample1"
                      }
                    }
                """.trimIndent()
            Assertions.assertThat(actualStatus).isEqualTo(expectedStatus)
            JSONAssert.assertEquals(
                expectedResponseBody,
                actualResponseBody,
                JSONCompareMode.STRICT
            )
        }
    }

POST リクエスト

POST リクエストの場合も、事前条件のデータセット挿入と API 操作とレスポンスの評価までは同様です。
@ExpectedDataSetで事後条件のデータセットを用意し、実際の DB と差異がないのかを確認します。@ExportDataSetによりテスト後のデータをファイルに書き出すことが可能です。そのため、一度実行して@ExportDataSetで書き出し問題なければ、@ExpectedDatasetに指定する、といったことが可能です。

今回の例では、common.ymlinsert-success.ymlは以下になります。

common.yml。
common.yml
customer:
  - id: 1
    first_name: "Alice"
    last_name: "Sample1"
  - id: 2
    first_name: "Bob"
    last_name: "Sample2"

insert-success.yml。
insert-success.yml
customer:
  - id: 1
    first_name: "Alice"
    last_name: "Sample1"
  - id: 2
    first_name: "Bob"
    last_name: "Sample2"
  - id: 1001
    first_name: "Carol"
    last_name: "Sample3"


    @SpringBootTest
    @AutoConfigureMockMvc
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @DBRider
    class RegisterCustomer @Autowired constructor(
        val mockMvc: MockMvc
    ) {
        @Test
        @DataSet("datasets/yml/given/common.yml")
        @ExpectedDataSet(
            value = ["datasets/yml/then/insert-success.yml"],
            orderBy = ["id"],
            ignoreCols = ["id"]
        )
        // @ExportDataSet(
        //     format = DataSetFormat.YML,
        //     outputName = "src/test/resources/datasets/yml/then/insert-success.yml",
        //     includeTables = ["customer"]
        // )
        fun `正常系`() {
            /**
             * given:
             */
            val rawRequestBody = """
                {
                    "customer": {
                        "firstName": "Carol",
                        "lastName": "Sample3"
                    }
                }
            """.trimIndent()

            /**
             * when:
             */
            val response = mockMvc.perform(
                MockMvcRequestBuilders
                    .post("/api/customers")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(rawRequestBody)
            ).andReturn().response

            val actualStatus = response.status
            val actualResponseBody = response.contentAsString

            /**
             * then:
             * - ステータスコードが一致する
             * - レスポンスボディが一致する
             */
            val expectedStatus = HttpStatus.OK.value()
            val expectedResponseBody =
                """
                    {
                      "customer": {
                        id: 1001,
                        firstName: "Carol",
                        lastName: "Sample3"
                      }
                    }
                """.trimIndent()
            Assertions.assertThat(actualStatus).isEqualTo(expectedStatus)
            JSONAssert.assertEquals(
                expectedResponseBody,
                actualResponseBody,
                CustomComparator(
                    JSONCompareMode.STRICT,
                    Customization("customer.id") { _, _ -> true } // id はインサート時に生成されるデータのため、比較しない。比較するのならばシーケンスの設定をいじる必要あり。
                )
            )
        }
    }

まとめ

MockMVC と DatabaseRider を組み合わせることで、API 統合テストを簡潔に記述できる方法を紹介しました。
ユニットテストだけでなく、API 統合テストを気軽に実施できる状態は、リグレッションを防ぎ実装スピードを向上できるので、ぜひ利用してみてください。

補足

MockMVC ではデフォルトでは、UTF-8 エンコードしないため、日本語のレスポンスが文字けっしてしまいます。
文字化けを回避するために、以下の設定が必要です(GitHub のリンク)。
テスト用の package の適当な箇所に実装するだけで、日本語が適切に表示されるので、同様の現象が発生したらこれで対応できます。

src/test/kotlin/com/example/kotlinspringmockmvcwithdatabaserider/apiintegration/helper/CustomizedMockMvc.kt
package com.example.kotlinspringmockmvcwithdatabaserider.apiintegration.helper

import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcBuilderCustomizer
import org.springframework.stereotype.Component
import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder

/**
 * MockMvc のカスタマイズ
 *
 * Response の Content-Type に"charset=UTF-8"を付与
 * 理由
 * - デフォルトだと日本語が文字化けするため
 */
@Component
class CustomizedMockMvc : MockMvcBuilderCustomizer {
    override fun customize(builder: ConfigurableMockMvcBuilder<*>?) {
        builder?.alwaysDo { result -> result.response.characterEncoding = "UTF-8" }
    }
}

参考

https://spring.pleiades.io/spring-framework/docs/current/reference/html/testing.html#spring-mvc-test-framework

https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#spring-mvc-test-framework

https://spring.pleiades.io/spring-boot/docs/current/reference/html/features.html#features.testing

https://tkhs0604.hatenablog.com/entry/junit5-test-instance-lifecycle

https://github.com/database-rider/database-rider

Discussion