4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

はじめに

こんにちは。オークファン開発部の2年目の@Soogle_1729です。
私の所属するチームでは、サーバーサイドのAPIをSpring BootとKotlinで実装することが多いです。

実装にテストはつきものです。
今回は、APIの入出力に対するテストを書いた際に、URLエンコードで2回もハマってしまったので、自らへの戒めと未来の後輩のために記事にまとめておこうと思います。

WebTestClientとは

Spring Flameworkが用意している、APIに対してリクエストを送信し、レスポンスの内容を検証できる素晴らしいインターフェースです。

さっそく、WebTestClientを使用してAPIのテストを書いていこうと思います。

WebTestClientを使ってみる

実際にWebTestClientを使って説明してみます。

検証環境

念のため環境について記述しておきます。

  • Spring Boot 2.7.5
  • Kotlin 1.6.21
  • Java 17
  • IntelliJ IDEA

サンプルAPIの作成

検証のために簡単なAPIを作成しました。(APIの実装内容は省略します)
GETメソッドで/api/sample?requestQuery={任意の値}とリクエストを送るとJSON形式で送信したクエリパラメータの値を返却してくれるものです。

実際にcurlでリクエストを送るとこのような感じでレスポンスが返ってきます。

$ curl 'http://localhost:8080/api/sample?requestQuery=hogehoge'
=> {"receivedQuery": "hogehoge" }

ちゃんとリクエストに含めたクエリパラメータの値(hogehoge)がレスポンスとして返却されていますね。

では、このAPIに対してテストを書いていこうと思います。

WebTestClientによるAPIのテスト

下記のように、テスト用のクラスを作成して、テストメソッドを記述します。
WebTestClientによってAPIに対してリクエストが送信され、exchange()以降でレスポンスの内容を検証することができます。非常に便利ですね。

コチラのテストは問題なく成功します。

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
)
internal class SampleApiIntegrationTest @Autowired constructor(
    private val webTestClient: WebTestClient
) {

    @Test
    @DisplayName("クエリパラメータに文字列を指定して、レスポンスの返却に成功する")
    fun sampleTest() {
        // webTestClientを使用してリクエストを送信
        webTestClient.get()
            .uri { uriBuilder ->
                uriBuilder.path("/api/sample")
                    // クエリパラメータをセット
                    .queryParam("requestQuery", "テスト")
                    .build()
            }
            .exchange()
            // ここからリクエストに対するレスポンスの検証
            .expectStatus().isOk
            .expectBody()
             // レスポンスのJSONを検証
            .jsonPath("$.receivedQuery").isEqualTo("テスト")
    }
}

WebTestClientの罠

通常テストケースが1つなんてことはありませんよね。
どうやらこのサンプルAPIは時刻をクエリパラメータとして受け取ることもあるようなので、時刻をリクエストする場合のテストを書いてみます。

私のチームでは、時刻の表示形式としてISO 8601に従うようにしているので、YYYY-MM-DDThh:mm:ss+09:00の形式でクエリパラメータに時刻をセットします。

// テストメソッドのみ記述
@Test
@DisplayName("クエリパラメータにISO時刻を指定して、レスポンスの返却に成功する??")
fun getSampleByDateTimeFormatTest() {
    webTestClient.get()
        .uri { uriBuilder ->
            uriBuilder.path("/api/sample")
                // クエリパラメータに時刻をセット
                .queryParam("requestQuery", "2022-11-20T00:00:00+09:00")
                .build()
        }
        .exchange()
        .expectStatus().isOk
        .expectBody()
        // 期待するレスポンス
        .jsonPath("$.receivedQuery").isEqualTo("2022-11-20T00:00:00+09:00")
}

勢いよくテストを実行してみます!
.
.
.
テストが失敗したようです。。。
ログをよくみると+部分が半角スペースとして返却されているようです。

JSON path "$.receivedQuery" expected:<2022-11-20T00:00:00+09:00> but was:<2022-11-20T00:00:00 09:00>
期待:2022-11-20T00:00:00+09:00
実際:2022-11-20T00:00:00 09:00

どうしてでしょうか?
そうです。URLエンコードするのを忘れてましたね。

調べてみると+はURLエンコードすると%2Bのようです。
なので時刻の部分を"2022-11-20T00:00:00%2B09:00"とエンコーディングしてあげれば問題なさそうです。

@Test
@DisplayName("クエリパラメータにURLエンコードしたISO時刻を指定して、レスポンスの返却に成功する??")
fun getSampleByEncodedDateTimeFormatTest() {
    webTestClient.get()
        .uri { uriBuilder ->
            uriBuilder.path("/api/sample")
                // クエリパラメータにURLエンコードされたISO時刻をセット
                .queryParam("requestQuery", "2022-11-20T00:00:00%2B09:00")
                .build()
        }
        .exchange()
        .expectStatus().isOk
        .expectBody()
        .jsonPath("$.receivedQuery").isEqualTo("2022-11-20T00:00:00+09:00")
}

今度こそ自信を持ってテストを実行します!
.
.
.
またもやテストが失敗したようです。。。
嫌々ログを確認すると、実際のレスポンスでは+の部分が%2B
のまま返却されてしまっています。

JSON path "$.receivedQuery" expected:<2022-11-20T00:00:00+09:00> but was:<2022-11-20T00:00:00%2B09:00>
期待:2022-11-20T00:00:00+09:00
実際:2022-11-20T00:00:00%2B09:00

どうやらこの書き方では、%2BをURLエンコードされた文字と認識してくれないようです。

前置きが多少長くなってしまいましたが、このテストを何とか成功させようと思います。

解決策その1_URIテンプレート変数を使用する

公式ドキュメントに従ってみます。
怪しそうなuriBuilder内のqueryParam()メソッドのドキュメントを見てみます。

注意 : エンコードが適用される場合、"=" や "&" などのクエリパラメーター名または値で無効な文字のみがエンコードされます。RFC 3986 の構文規則に従って正当な他のすべてはエンコードされません。これには、エンコードされたスペースとしての解釈を避けるためにエンコードされる必要がある場合がある "+" が含まれます。URI テンプレート変数と変数値のより厳密なエンコーディングを使用して、より厳密なエンコーディングを適用できます。詳細については、Spring Framework リファレンスの「URI エンコーディング」セクションを参照してください。

わざわざ注意書きで、RFC 3986の構文規則で正当な+はエンコードされないと書いてありますね。。。

ドキュメントに記載されているSpring Frameworkのリファレンスを見てみてみると、下記のようにURIテンプレート変数を使用して、値はbuild()内で渡してあげればいいようです。

// リファレンスのサンプルコード
val uri = UriComponentsBuilder.fromPath("/hotel list/{city}")
        .queryParam("q", "{q}")
        .build("New York", "foo+bar")

// uri: "/hotel%20list/New%20York?q=foo%2Bbar"

URI⁠テンプレート変数の{q}に渡している"foo+bar" が正しくエンコードされ"foo%2Bbar" となっています。

この書き方に倣って、時刻のテストを書いてみます!

@Test
@DisplayName("クエリパラメータのISO時刻をテンプレート変数として指定して、レスポンスの返却に成功する")
fun getSampleByDateTimeFormatInUriTemplateTest() {
    webTestClient.get()
        .uri { uriBuilder ->
            uriBuilder.path("/api/sample")
                // クエリパラメータにURIテンプレート変数をセット
                .queryParam("requestQuery", "{requestQuery}")
                // URIテンプレート変数にISO時刻をセット
                .build("2022-11-20T00:00:00+09:00")
        }
        .exchange()
        .expectStatus().isOk
        .expectBody()
        .jsonPath("$.receivedQuery").isEqualTo("2022-11-20T00:00:00+09:00")
}

祈りを捧げながらテストを実行します。
.
.
.
テストが成功しました!

':test --tests "com.example.demo.presentation.SampleApiIntegrationTest.getSampleByDateTimeFormatInUriTemplateTest"' の実行を完了しました。

URLエンコードしてくれない文字列が含まれる場合は、URIテンプレート変数として記述すればよいのですね〜
公式ドキュメントの偉大さを痛感しました。

ちなみにですが、build()にはMapを渡すこともできるため、URIテンプレート変数が複数ある場合などに下記のようにすると便利です。

// テストメソッドの抜粋
webTestClient.get()
    .uri { uriBuilder ->
        uriBuilder.path("/api/sample")
            // URIテンプレート変数を複数使用してみる    
            .queryParam("startDateTIme", "{startDateTIme}")
            .queryParam("endDateTIme", "{endDateTIme}")
            .build(
                // URIテンプレート変数を置き換えたい値をMapで渡す
                mapOf(
                    "startDateTIme" to "2022-11-20T00:00:00+09:00",
                    "endDateTIme" to "2022-11-30T00:00:00+09:00"
                )
            )
    }
// 略

公式ドキュメントをみた限りでは、上記のやり方がよさそうです。
ただ、+を含むクエリパラメータが増えてくるとqueryParamMapをどちらも記述していくのがちょっと面倒です。

URIエンコードの設定を変更することで、もう少しシンプルに記載できないか検討してみます。

解決策その2_フィルター関数を使用する

しばらくググっているとこちらの記事を見つけました。

WebClientfilter()関数を使用してリクエストの送信前に独自の処理を挟んでから、リクエストを送信しようというアプローチです。

記事の内容を参考にしながら、WebTestClientをカスタマイズしたcustomWebTestClientを定義します。

filterPlusSignEncoding()内でリクエストのURIに含まれる+%2Bへ置き換えています。

// .filter()関数を適用して、独自のfilterPlusSignEncoding()による処理を追加する
val customWebTestClient = webTestClient.mutate().filter(filterPlusSignEncoding()).build()

// webTestClientに追加する処理
private fun filterPlusSignEncoding(): ExchangeFilterFunction {
    return ExchangeFilterFunction { clientRequest: ClientRequest, nextFilter: ExchangeFunction ->
        // リクエストのURIに含まれる"+"を"%2B"へ置き換える
        val encodedUrl = StringUtils.replace(clientRequest.url().toString(), "+", "%2B")
        val filteredRequest = ClientRequest.from(clientRequest)
            .url(URI.create(encodedUrl))
            .build()
        nextFilter.exchange(filteredRequest)
    }
}

あとは、新しく作成したcustomWebTestClientを使用してテストメソッドを記述すれば完了です!

@Test
@DisplayName("クエリパラメータにISO時刻を指定して、レスポンスの返却に成功する")
fun getSampleByDateTimeFormatByCustomWebClientTest() {
    // 先ほど定義したcustomWebTsetClientを使用する
    customWebTestClient.get()
        .uri { uriBuilder ->
            uriBuilder.path("/api/sample")
                // クエリパラメータにISO時刻をセット
                .queryParam("requestQuery", "2022-11-20T00:00:00+09:00")
                .build()
        }
        .exchange()
        .expectStatus().isOk
        .expectBody()
        .jsonPath("$.receivedQuery").isEqualTo("2022-11-20T00:00:00+09:00")
}

最後の力を振り絞ってテストを実行します!
.
.
.
テストが無事に成功しました!!!!
これでURIテンプレート変数をいちいち定義しなくてもテストを書くことができます。

':test --tests "com.example.demo.presentation.SampleApiIntegrationTest.getSampleByDateTimeFormatByCustomWebClientTest"' の実行を完了しました。

おわりに

今回はWebTestClientに悩まされた経験について記載してみました。
作成したサンプルのソースコードはこちらにあげておきます。
「もっと良い書き方があるぞ!」や「説明間違ってるぞ!」といったご指摘があれば、教えていただけると嬉しいです。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?