🫖

Kotlin(Spring Boot)の API テスト(MockMVC)で CustomComparator を用いて独自の比較方法を作成

2022/11/04に公開

概要

普段、サーバーサイド Kotlin(Spring Boot)の API テストでは、MockMVC を利用しています。
API テストでよく困ることに、実行ごとに値が変わるキー(uuid、updatedAt、createdAt など)の存在があります。テストの期待値にそれらのキーを固定値として用意してしまうと、テストが通らなくなる場合もあります。
本記事では、CustomComparator を用いて、動的に変わるキーに対してさまざまな比較方法(キーの存在確認、正規表認)を定義して、レスポンスを評価する方法を紹介します。

本記事では、以下の構成に従って進めます。

概要 内容
前提 本記事で扱うサンプルコードの API と JSONCompareMode について説明します
MockMVC の JSON の比較方法 JSON の比較方法を紹介します
まとめ 記事のまとめです

本記事で作成したサンプルコードは以下になります。

https://github.com/Msksgm/kotlin-spring-boot-mockmvc-customcomparator

前提

本記事で利用する API 設計について

本記事で作成した API 設計を、以下のページから確認できます。

https://msksgm.github.io/kotlin-spring-boot-mockmvc-customcomparator/swagger/

リンク先の画像例。

API設計
API 設計

DB との接続はなく、リクエストに対してハードコーディングされています。
OpenAPI Generator を利用して、opnapi.yaml からコントローラを半自動生成しました。

./src/main/kotlin/com/example/realworldkotlinspringbootjdbc/presentation/ArticleController.kt
@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 はメタデータです。そのため、実行のたびに更新されます。これらをどのように比較していくのかを次項以降で解説します。

POSTリクエスト
./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)を使用します。

Extensible=yesのときに以下は通過する。本記事では適切に評価したい
expected={id:1,name:"Carter"}
actual={id:1,name:"Carter",createdAt:"00:00:00",updatedAt:"00:00:00"}

CustomComparator を用いた比較方法の定義

以降は JSON 比較方法について詳細に説明します。
初めに必ず失敗するパターンを紹介した後に、CustomComparatorを用いた解決策を紹介します。
サンプルコードのテスト部分を抜粋しますので、詳細は以下のリンクを参照にしてください。

https://github.com/Msksgm/kotlin-spring-boot-mockmvc-jsonassert-regex/blob/main/src/test/kotlin/com/example/kotlinspringbootmockmvcjsonassertregex/api/ArticleTest.kt

失敗パターン

以下は、 単純比較したテストパターンです。
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,
)
失敗パターンのテスト全文。
./src/test/kotlin/com/example/kotlinspringbootmockmvcjsonassertregex/api/ArticleTest.kt
        @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を用いることで、特定のキーに対して別の評価方法を適用できます。

特定のキーを通す

まずは、どのような値がきても必ずテストを通す方法です。キーが存在すればテストが通過します。
CustomComparatorCustomizationが特定のキーに対して評価します。_, _ -> true は expected と actual がそれぞれ何がきても true を評価することを示します。また、article.createAtarticle.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 テストを充実できると、変更に対して頑健になるので、積極に利用してみてください。

参考

https://nainaistar.hatenablog.com/entry/2020/06/15/JavaでJsonを比較する(特定の項目を無視するやり方)

http://jsonassert.skyscreamer.org/javadoc/org/skyscreamer/jsonassert/JSONCompareMode.html

Discussion