Kotlin(Spring Boot)の API テスト(MockMVC)で CustomComparator を用いて独自の比較方法を作成
概要
普段、サーバーサイド Kotlin(Spring Boot)の API テストでは、MockMVC を利用しています。
API テストでよく困ることに、実行ごとに値が変わるキー(uuid、updatedAt、createdAt など)の存在があります。テストの期待値にそれらのキーを固定値として用意してしまうと、テストが通らなくなる場合もあります。
本記事では、CustomComparator を用いて、動的に変わるキーに対してさまざまな比較方法(キーの存在確認、正規表認)を定義して、レスポンスを評価する方法を紹介します。
本記事では、以下の構成に従って進めます。
概要 | 内容 |
---|---|
前提 | 本記事で扱うサンプルコードの API と JSONCompareMode について説明します |
MockMVC の JSON の比較方法 | JSON の比較方法を紹介します |
まとめ | 記事のまとめです |
本記事で作成したサンプルコードは以下になります。
前提
本記事で利用する API 設計について
本記事で作成した API 設計を、以下のページから確認できます。
リンク先の画像例。
API 設計
DB との接続はなく、リクエストに対してハードコーディングされています。
OpenAPI Generator を利用して、opnapi.yaml からコントローラを半自動生成しました。
@RestController
class ArticleController : ArticlesApi {
override fun createArticle(newArticleRequest: NewArticleRequest): ResponseEntity<SingleArticleResponse> {
return ResponseEntity(
SingleArticleResponse(
Article(
title = "dummy-title",
description = "dummy-description",
body = "dummy-body",
createdAt = OffsetDateTime.now(),
updatedAt = OffsetDateTime.now()
)
),
HttpStatus.CREATED
)
}
}
この API に対して POST リクエストを実行すると、以下の結果が得られます。
レスポンスの createdAt と updatedAt はメタデータです。そのため、実行のたびに更新されます。これらをどのように比較していくのかを次項以降で解説します。
./gradlew bootRun
curl --location --request POST 'http://localhost:8080/api/articles' \
--header 'Content-Type: application/json' \
--data-raw '{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body"
}
}'
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body",
"createdAt": "2022-11-02T06:40:39.419397+09:00",
"updatedAt": "2022-11-02T06:40:39.419425+09:00"
}
}
JSONCompareMode について
本記事の本質から話がそれますが、ソースコードに登場するので、JSONCompareMode について説明します。
JSONCompareMode とは、JSONAssert を用いて JSON を比較する際に使用するオプションです。
JSONCompareMode には 4 種類(STRICT、LENIENT、NON_EXTENSIBLE、STRICT_ORDER)があります。
Extensible Strict Ordering STRICT no yes LENIENT yes no NON_EXTENSIBLE no no STRICT_ORDER yes yes http://jsonassert.skyscreamer.org/javadoc/org/skyscreamer/jsonassert/JSONCompareMode.html より引用
Extensible と Strict Ordering の意味はそれぞれ以下になっています。
- Extensible
- 実際の JSON が期待値の JSON よりも、キーの数が多くても許容する
- Strict Ordering
- array 型があった場合、順序を厳密に評価する
具体的には、JSONAssert のドキュメントから例を引用して解説します。
以下は、Extensible=yes のとき true、Extensible=no のとき false と評価します。
{id:1,name:"Carter"}
{id:1,name:"Carter",favoriteColor:"blue"}
以下は、Strict Ordering=yes のとき false、Strict Ordering=no のとき true と評価します。
{id:1,friends:[{id:2},{id:3}]}
{id:1,friends:[{id:3},{id:2}]}
Extensible=yes にすることで、createdAt、updatedAt などのキーを無視できます(以下参照)。
しかし、本記事では JSON のキーを漏れなく評価し、配列の順序に結果が左右されたくないことを想定します。
そのためJSONCompareMode.NON_EXTENSIBLE
(Extensible=yes、Strict Ordering=no)を使用します。
expected={id:1,name:"Carter"}
actual={id:1,name:"Carter",createdAt:"00:00:00",updatedAt:"00:00:00"}
CustomComparator を用いた比較方法の定義
以降は JSON 比較方法について詳細に説明します。
初めに必ず失敗するパターンを紹介した後に、CustomComparator
を用いた解決策を紹介します。
サンプルコードのテスト部分を抜粋しますので、詳細は以下のリンクを参照にしてください。
失敗パターン
以下は、 単純比較したテストパターンです。
createdAt と updatedAt が実行時に依存されるため、必ずテストが落ちます。
/**
* then:
* - ステータスコードが一致する
* - レスポンスボディが一致する -> createdAt と updatedAt は実行時によって更新されるため、必ず失敗する
*/
val expectedStatus = HttpStatus.CREATED.value()
val expectedResponseBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body",
"createdAt": "2022-01-01T00:00:00.000000+09:00",
"updatedAt": "2022-01-01T00:00:00.000000+09:00"
}
}
""".trimIndent()
assertThat(actualStatus).isEqualTo(expectedStatus)
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
JSONCompareMode.NON_EXTENSIBLE,
)
失敗パターンのテスト全文。
@Test
fun `正常系-createdAt と updatedAt で比較できずに落ちるテスト`() {
/**
* given:
*/
val requestBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body"
}
}
""".trimIndent()
/**
* when:
*/
val resposne = mockMvc.post("/api/articles") {
contentType = MediaType.APPLICATION_JSON
content = requestBody
}.andReturn().response
val actualStatus = resposne.status
val actualResponseBody = resposne.contentAsString
/**
* then:
* - ステータスコードが一致する
* - レスポンスボディが一致する -> createdAt と updatedAt は実行時によって更新されるため、必ず失敗する
*/
val expectedStatus = HttpStatus.CREATED.value()
val expectedResponseBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body",
"createdAt": "2022-01-01T00:00:00.000000+09:00",
"updatedAt": "2022-01-01T00:00:00.000000+09:00"
}
}
""".trimIndent()
assertThat(actualStatus).isEqualTo(expectedStatus)
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
JSONCompareMode.NON_EXTENSIBLE,
)
}
以下のようなエラーが発生します。
article.createdAt
Expected: 2022-01-01T00:00:00.000000+09:00
got: 2022-11-02T06:51:59.921254+09:00
; article.updatedAt
Expected: 2022-01-01T00:00:00.000000+09:00
got: 2022-11-02T06:51:59.921343+09:00
成功パターン
上記の問題を解決するために、CustomComparator
を利用します。
CustomComparator
を用いることで、特定のキーに対して別の評価方法を適用できます。
特定のキーを通す
まずは、どのような値がきても必ずテストを通す方法です。キーが存在すればテストが通過します。
CustomComparator
のCustomization
が特定のキーに対して評価します。_, _ -> true
は expected と actual がそれぞれ何がきても true を評価することを示します。また、article.createAt
、article.updatedAt
に該当するキーが存在しない場合はテストが落ちます。
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
CustomComparator(
JSONCompareMode.NON_EXTENSIBLE,
Customization("article.createdAt") { _, _ -> true },
Customization("article.updatedAt") { _, _ -> true },
)
)
テスト全文。
@Test
fun `正常系-特定のキーが存在すればテストを通す`() {
/**
* given:
*/
val requestBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body"
}
}
""".trimIndent()
/**
* when:
*/
val response = mockMvc.post("/api/articles") {
contentType = MediaType.APPLICATION_JSON
content = requestBody
}.andReturn().response
val actualStatus = response.status
val actualResponseBody = response.contentAsString
/**
* then:
* - ステータスコードが一致する
* - レスポンスボディが一致する
*/
val expectedStatus = HttpStatus.CREATED.value()
val expectedResponseBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body",
"createdAt": "2022-01-01T00:00:00.000000+09:00",
"updatedAt": "2022-01-01T00:00:00.000000+09:00"
}
}
""".trimIndent()
assertThat(actualStatus).isEqualTo(expectedStatus)
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
CustomComparator(
JSONCompareMode.NON_EXTENSIBLE,
Customization("article.createdAt") { _, _ -> true },
Customization("article.updatedAt") { _, _ -> true },
)
)
}
正規表現で評価する
createdAt と updatedAt は時刻を表すデータのため、形式(ISO 8601 の UTC なのか JST なのかなど)を評価したい場合があります。そこで、正規表現を用いた評価方法があります。
以下は、正規表現を用いて評価した例です。関数として切り出すことで、ISO 形式であるかと OffsetDateTime にパースできるか検証します。
fun expectIso8601UtcAndParsable(o: Any): Boolean {
assertThat(o.toString())
.`as`("YYYY-MM-DDTHH:mm:ss.SSSXXX(ISO8601形式)で、TimeZoneはUTCである")
.matches("([0-9]{4})-([0-9]{2})-([0-9]{2}T([0-9]{2}):([0-9]{2}):([0-9]{2}).([0-9]{6})\\+09:00)")
return runCatching { OffsetDateTime.parse(o.toString()) }.isSuccess
}
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
CustomComparator(
JSONCompareMode.NON_EXTENSIBLE,
Customization("article.createdAt") { actualCreatedAt, _ ->
expectIso8601UtcAndParsable(actualCreatedAt)
},
Customization("article.updatedAt") { actualUpdatedAt, _ ->
expectIso8601UtcAndParsable(actualUpdatedAt)
},
)
)
テスト全文。
@Test
fun `正常系-特定のキーが正規表現と一致すればテストを通す`() {
/**
* given:
*/
val requestBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body"
}
}
""".trimIndent()
/**
* when:
*/
val response = mockMvc.post("/api/articles") {
contentType = MediaType.APPLICATION_JSON
content = requestBody
}.andReturn().response
val actualStatus = response.status
val actualResponseBody = response.contentAsString
/**
* then:
* - ステータスコードが一致する
* - レスポンスボディが一致する
*/
val expectedStatus = HttpStatus.CREATED.value()
val expectedResponseBody = """
{
"article": {
"title": "dummy-title",
"description": "dummy-description",
"body": "dummy-body",
"createdAt": "2022-01-01T00:00:00.000000+09:00",
"updatedAt": "2022-01-01T00:00:00.000000+09:00"
}
}
""".trimIndent()
assertThat(actualStatus).isEqualTo(expectedStatus)
fun expectIso8601UtcAndParsable(o: Any): Boolean {
assertThat(o.toString())
.`as`("YYYY-MM-DDTHH:mm:ss.SSSXXX(ISO8601形式)で、TimeZoneはUTCである")
.matches("([0-9]{4})-([0-9]{2})-([0-9]{2}T([0-9]{2}):([0-9]{2}):([0-9]{2}).([0-9]{6})\\+09:00)")
return runCatching { OffsetDateTime.parse(o.toString()) }.isSuccess
}
JSONAssert.assertEquals(
expectedResponseBody,
actualResponseBody,
CustomComparator(
JSONCompareMode.NON_EXTENSIBLE,
Customization("article.createdAt") { actualCreatedAt, _ ->
expectIso8601UtcAndParsable(actualCreatedAt)
},
Customization("article.updatedAt") { actualUpdatedAt, _ ->
expectIso8601UtcAndParsable(actualUpdatedAt)
},
)
)
}
まとめ
本記事では、JSONAssert で CustomComparator を利用して、独自の比較方法で評価する方法を紹介しました。
今回は時刻に対して評価しましたが、ほかの実行時に対して値が変わるデータに対しても有効です。
また、正規表現以外の評価方法も定義可能です。
MockMVC を用いた API テストを充実できると、変更に対して頑健になるので、積極に利用してみてください。
参考
Discussion