Kotlin(Spring Boot)+ MockMVC + DatabaseRider で簡単 API 統合テスト
概要
本記事では、サーバーサイド 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 に挙げています。
以下に本記事で利用する API のサンプルコードを載せました。
MockMVC
MockMVC とは、Spring Test に含まれるテストフレームワークの 1 つです。
実装したサーバに対して、手動でリクエストを送ることなく、API テストが可能です。
MockMVC は初期化のために、@AutoConfigureMockMvc と@SpringBootTest が必要です。
class SampleTest {
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class Get @Autowired constructor(
val mockMvc: MockMvc
) {
@BeforeAll
fun reset() = // テストのたびに実行される処理
@Test
fun `テスト`() {
}
}
}
以下のように MockMvc を利用することで、リクエストパラメータを送ります。
レスポンスステータスとレスポンスボディも評価できます。
val response = mockMvc.perform(
MockMvcRequestBuilders
.get("/path/to/api")
.contentType(MediaType.APPLICATION_JSON)
).andReturn().response
val actualStatus = response.status
val actualResponseBody = response.contentAsString
DatabaseRider
DatabaseRider とは、アノテーションによって、DB テストを簡潔に記述できる Java のライブラリです。
以下のように、アノテーションによって、入力データの挿入、テスト後の DB の期待値、テスト完了後のテーブルのファイルへの書き出し、が可能です。
つまり、テスト前に DB に対して挿入されるデータを指定し、テスト後に DB があるべきデータを比較可能です。
API 統合テストでレスポンス以外に DB の状態を保証できるのは、API テストと非常に相性が良く採用しました。
@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 について詳細は以下の記事を参考にしてください。
解説
これから、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
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.yml
とinsert-success.yml
は以下になります。
common.yml。
customer:
- id: 1
first_name: "Alice"
last_name: "Sample1"
- id: 2
first_name: "Bob"
last_name: "Sample2"
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 の適当な箇所に実装するだけで、日本語が適切に表示されるので、同様の現象が発生したらこれで対応できます。
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" }
}
}
参考
Discussion