Spring BootのWebTestClientで"+"をURLエンコードしたい

# はじめに

こんにちは。オークファン開発部の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`でリクエストを送るとこのような感じでレスポンスが返ってきます。

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

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

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

## WebTestClientによるAPIのテスト

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

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

```kotlin
@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](https://ja.wikipedia.org/wiki/ISO_8601)に従うようにしているので、`YYYY-MM-DDThh:mm:ss+09:00`の形式でクエリパラメータに時刻をセットします。

```kotlin
// テストメソッドのみ記述
@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")
}
```

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

```console
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"`とエンコーディングしてあげれば問題なさそうです。

```kotlin
@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`
のまま返却されてしまっています。

```console
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()`内で渡してあげればいいようです。

```kotlin
// リファレンスのサンプルコード
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"` となっています。

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

```kotlin
@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")
}
```

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

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

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

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

```kotlin
// テストメソッドの抜粋
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"
                )
            )
    }
// 略
```

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

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

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

しばらくググっていると[こちらの記事](https://zenn.dev/satto_sann/articles/5b388fef0d5920)を見つけました。

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

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

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

```kotlin
// .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`を使用してテストメソッドを記述すれば完了です！

```kotlin
@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テンプレート変数`をいちいち定義しなくてもテストを書くことができます。

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

# おわりに

今回は`WebTestClient`に悩まされた経験について記載してみました。
作成したサンプルのソースコードは[こちら](https://github.com/aucfan-suguru-yamada/sample-web-test-client)にあげておきます。
「もっと良い書き方があるぞ！」や「説明間違ってるぞ！」といったご指摘があれば、教えていただけると嬉しいです。