SpringのRestClientの利用と、MockRestServiceServerを利用したテスト

# やりたいこと
Spring 6.1（Spring Bootだと3.2）から導入された`RestClient`を使う、そしてそのテストを書きたいです。

公式ドキュメント -> https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient

今回は以前の記事で`RestTemplate`で作成したもの([記事](https://qiita.com/suke_masa/items/c47bfaa20b39dcca30a4))を、`RestClient`に置き換えてみます。

# 環境
- JDK 21
- Spring Boot 3.3

サンプルコード -> https://github.com/MasatoshiTada/rest-client-sample

# Web API仕様
## GET /api/hello
ステータスコード200、レスポンスボディは下記

```json:レスポンスボディ
{"message": "hello"}
```

## POST /api/hello
リクエストボディは下記

```json:リクエストボディ
{"message": "hello", "date":"2024-12-31"}
```

レスポンスはステータスコード200、ボディなし


# `RestClient`の基本的な使い方
## インスタンス生成
`RestClient.Builder`から`RestClient`を生成できます。
Spring Boot利用時は`RestClient.Builder`のみBean定義済みになっています。ただし、この定義済みBeanを利用するとテストが面倒になるので（理由は後述）、Spring Boot利用時でも`RestClient.Builder`を自前でBean定義したほうがいいです。

`RestClient.Builder`では、`RestClient`の様々な設定を記述できます。今回はタイムアウト時間と、ステータスコードが4xx/5xxのときに例外にならないように設定しています。その他にも設定項目はたくさんありますので、詳しくは[Javadoc](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.Builder.html)をご確認ください。

`RestClientBuilderConfigurer`はSpring BootのAuto ConfigurationでBean定義済みになっています。これが持っている設定を`configure()`メソッドで`RestClient.Builder`にも適用します。これをしないと、`java.time.LocalDate`が配列に変換されてしまうので忘れないようにしてください。

Bean定義した`RestClient.Builder`から`RestClient`のBeanも定義します。この時、`baseUrl`も設定しておくことがポイントです。これをやらないとテストが面倒になります（理由は後述）。もしアクセスするシステムが複数ある場合は、`RestClient`のBeanもシステムの数だけ作り、`@Qualifier`（[参考記事](https://qiita.com/suke_masa/items/9372424ef4d061d1d084)）で使い分ける感じになります。

```java:RestClientConfig.java
@Configuration
public class RestClientConfig {

    @Bean
    public RestClient.Builder restClientBuilder(RestClientBuilderConfigurer configurer) {
        ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
                .withConnectTimeout(Duration.ofSeconds(5))  // 接続タイムアウト
                .withReadTimeout(Duration.ofSeconds(5));  // 読み取りタイムアウト
        RestClient.Builder builder = RestClient.builder()
                .defaultStatusHandler(
                        // ステータスコードが4xx・5xxの場合に例外が出ないようにする
                        status -> true,
                        (request, response) -> { /* 何もしない */ })
                .requestFactory(ClientHttpRequestFactories.get(settings));
        // これが無いとLocalDateが配列に変換されてしまう
        configurer.configure(builder);
        return builder;
    }

    @Bean
    public RestClient restClient(RestClient.Builder restClientBuilder, @Value("${hello-service.base-url}") String baseUrl) {
        RestClient restClient = restClientBuilder.baseUrl(baseUrl).build();  // テスト時に使われないように、baseUrlのみこちらで設定
        return restClient;
    }
}
```

application.propertiesには、Jacksonのタイムゾーンと日付フォーマット形式を指定します。

```application.properties
hello-service.base-url=http://example.com

spring.jackson.time-zone=Asia/Tokyo
spring.jackson.date-format=com.fasterxml.jackson.databind.util.ISO8601DateFormat
```

## `RestClient`をDI

```java:HelloClient.java
@Component
public class HelloClient {

    private final RestClient restClient;

    public HelloClient(RestClient restClient) {
        this.restClient = restClient;
    }
    ...
}
```

## GETリクエストの送信
```java:HelloClient.java
    public HelloResponse getHello() {
        ResponseEntity<HelloResponse> responseEntity = restClient.get()
                .uri("/api/hello")
                .retrieve()
                .toEntity(HelloResponse.class);
        if (responseEntity.getStatusCode().isError()) {
            throw new RuntimeException("error");
        }
        return responseEntity.getBody();
    }
```

```java:HelloResponse.java
// レスポンスされるJSONを受け取るクラス
public record HelloResponse(String message) {
}
```

`.get()`でGETリクエストであることを示し、`.uri()`でURLを指定します。`RestClient.Builder`にベースURLを指定しているため、ここにはそれ以降の`/api/hello`のみでOKです。

リクエストヘッダーを指定したい場合は`.header()`で指定します。

```java:ヘッダー指定の例
        ResponseEntity<HelloResponse> responseEntity = restClient.get()
                .uri("/api/hello")
                .header("ヘッダー名", "値")
                .retrieve()
                .toEntity(HelloResponse.class);
```

`.retrieve()`以降にレスポンスに関する記述を行います。`.toEntity(HelloResponse.class)`とすると、レスポンスボディを`HelloResponse`で受け取った上で、それを内包する`ResponseEntity`が戻り値になります。

## POSTリクエストの送信
```java:HelloClient.java
    public String postHello(HelloRequest request) {
        ResponseEntity<Void> responseEntity = restClient.post()
                .uri("/api/hello")
                .body(request)
                .retrieve()
                .toBodilessEntity();
        if (responseEntity.getStatusCode().isError()) {
            throw new RuntimeException("error");
        }
        return "OK";
    }
```

```java:HelloRequest.java
// リクエストボディのJSONに変換されるクラス
public record HelloRequest(String message) {
}
```

`.post()`でPOSTリクエストであることを示し、`.uri()`でURLを、`.body()`でリクエストボディを指定します。

`.retrieve()`以降にレスポンスに関する記述を行います。レスポンスボディが無い場合は`.toBodilessEntity()`とすると、`ResponseEntity<Void>`が戻り値になります。

## PUT・DELETE・PATCH
もちろん、PUT・DELETE・PATCHリクエストも送信可能です。

```java:PUT・DELETE・PATCHの例
// PUT
restClient.put().uri("...")...
// DELETE
restClient.delete().uri("...")...
// PATCH
restClient.patch().uri("...")...
```

# `RestClient`のテストで必要な工夫
`MockRestServiceServer`を利用してテストする場合は、いくつか工夫が必要です。

## 工夫1: `RestClient.Builder`を自前でBean定義しておく
Spring BootのAuto Configurationクラスで定義済みの`RestClient.Builder`のBeanは、何故かプロトタイプスコープになっています（[該当箇所](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java#L82)）。

これにより`MockRestServiceServer`適用後の`RestClient.Builder`のBeanとは**異なる**　`RestClient.Builder`Beanが使われるので、うまくテストができません。

なので、`RestClient.Builder`のBeanをシングルトンにするために、あえて自前でBean定義していたのです。

## 工夫2: `RestClient`に`baseUrl`を設定済みにしておく
`MockRestServiceServer`を利用したテストでは、URLはドメインより後の部分のみ指定する必要があります。

```java
// OKな例
restClient.get().uri("/api/hello")...
// NGな例
restClient.get().uri("http://localhost:8080/api/hello")...
```

なので、`RestClient`に`baseUrl`を設定済みにしておきましょう。

# `MockRestServiceServer`を利用したテスト
## テスト用のBean定義
```java:TestConfig.java
@Configuration
public class TestConfig {

    @Bean
    public MockRestServiceServer mockRestServiceServer(RestClient.Builder builder) {
        // RestClient.BuilderのRequestFactoryを書き換え
        MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
        return server;
    }

    @Bean
    @Primary
    @DependsOn("mockRestServiceServer")  // mockRestServiceServer()が先に実行されるようにする
    public RestClient restClient(RestClient.Builder restClientBuilder) {
        RestClient restClient = restClientBuilder.build();
        return restClient;
    }
}
```

`MockRestServiceServer`をBean定義します。その際、`RestClient.Builder`のBeanを受け取って、それに対して`bindTo()`を実行します。この処理を`RestClient`生成前に行うのがポイントです。

そのために`restClient()`メソッドに`@DependsOn("mockRestServiceServer")`を付加します。これにより「`restClient` Beanは`mockRestServiceServer` Beanに依存するので、`mockRestServiceServer` Beanを先に生成してね（＝`mockRestServiceServer()`メソッドを先に実行してね）」とDIコンテナに伝えます。

加えて、`restClient()`メソッドには`@Primary`を付加します。これにより、`RestClientConfig`で定義された`restClient` Beanを上書きすることができます。この上書きを有効化するためには、以下の設定も必要です。

```properties:src/test/resources/application-default.properties
spring.main.allow-bean-definition-overriding=true
```

## テストクラスの作成
```java:HelloClientTest.java
@SpringBootTest(classes = {
        RestClientSampleApplication.class,
        TestConfig.class
})
class HelloClientTest {

    @Autowired
    HelloClient helloClient;

    @Autowired
    MockRestServiceServer server;
    ...
}
```

`MockRestServiceServer`と、テスト対象の`HelloClient`をDIします。

`@SpringBootTest`の`classes`要素には`TestConfig`に加えて、`@SpringBootApplication`が付いたクラスを指定します。

## テストの作成
```java:HelloClientTest.java
    @Nested
    @DisplayName("getHello()")
    class GetHelloTest {
        @Test
        @DisplayName("GETリクエストを送信してサーバーから200が返ると、レスポンスボディをHelloResponseで受け取れる")
        void success() {
            // モックサーバーを設定する
            server.expect(requestTo("/api/hello"))  // このURLに
                    .andExpect(method(HttpMethod.GET))  // GETリクエストすると
                    .andRespond(withSuccess("""
                            {"message":"hello"}
                            """, MediaType.APPLICATION_JSON));  // 200 OKとこんなJSONを返すよう設定する
            // テスト実行＋アサーション
            HelloResponse actual = helloClient.getHello();
            assertEquals("hello", actual.message());
        }

        @Test
        @DisplayName("GETリクエストを送信してサーバーから500が返ると、RuntimeExceptionがスローされる")
        void error() {
            // モックサーバーを設定する
            server.expect(requestTo("/api/hello"))  // このURLに
                    .andExpect(method(HttpMethod.GET))  // GETリクエストすると
                    .andRespond(withServerError());  // 500が返る
            // テスト実行＋アサーション
            assertThrows(RuntimeException.class, () -> helloClient.getHello());
        }
    }

    @Nested
    @DisplayName("postHello()")
    class PostHelloTest {
        @Test
        @DisplayName("JSONをPOSTしてサーバーから200が返ると、'OK'が返る")
        void success() {
            // モックサーバーを設定する
            server.expect(requestTo("/api/hello"))  // このURLに
                    .andExpect(method(HttpMethod.POST))  // POSTリクエストで
                    .andExpect(content().json("""
                            {"message": "hello", "date": "2024-12-31"}
                            """))  // こんなJSONを送信すると
                    .andRespond(withStatus(HttpStatus.OK).body(""));  // 200が返るよう設定する
            // テスト実行＋アサーション
            String actual = helloClient.postHello(new HelloRequest("hello", LocalDate.of(2024, 12, 31)));
            assertEquals("OK", actual);
        }

        @Test
        @DisplayName("JSONをPOSTしてサーバーから500が返ると、RuntimeExceptionがスローされる")
        void error() {
            // モックサーバーを設定する
            server.expect(requestTo("/api/hello"))  // このURLに
                    .andExpect(method(HttpMethod.POST))  // POSTリクエストで
                    .andExpect(content().json("""
                            {"message": "hello", "date": "2024-12-31"}
                            """))  // こんなJSONを送信すると
                    .andRespond(withServerError());  // 500が返るように設定する
            // テスト実行＋アサーション
            assertThrows(RuntimeException.class, () -> helloClient.postHello(new HelloRequest("hello", LocalDate.of(2024, 12, 31))));
        }
    }
```

まず`MockRestServiceServer`にモックサーバーの設定をします。どんなURLに、どんなリクエストメソッドでリクエストすると、どんなステータスコードとどんなレスポンスボディが返るかを設定します。

その後、テスト対象メソッドの実行とアサーションを行います。

## 複数リクエストへの対応
https://qiita.com/kazuki43zoo/items/fa9fea1c813f76080fe7#%E8%A4%87%E6%95%B0%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF