5
5

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

Last updated at Posted at 2024-08-08

やりたいこと

Spring 6.1(Spring Bootだと3.2)から導入されたRestClientを使う、そしてそのテストを書きたいです。

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

今回は以前の記事でRestTemplateで作成したもの(記事)を、RestClientに置き換えてみます。

環境

  • JDK 21
  • Spring Boot 3.3

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

Web API仕様

GET /api/hello

ステータスコード200、レスポンスボディは下記

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

POST /api/hello

リクエストボディは下記

リクエストボディ
{"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をご確認ください。

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

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

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

HelloClient.java
@Component
public class HelloClient {

    private final RestClient restClient;

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

GETリクエストの送信

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();
    }
HelloResponse.java
// レスポンスされるJSONを受け取るクラス
public record HelloResponse(String message) {
}

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

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

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

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

POSTリクエストの送信

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";
    }
HelloRequest.java
// リクエストボディのJSONに変換されるクラス
public record HelloRequest(String message) {
}

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

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

PUT・DELETE・PATCH

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

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は、何故かプロトタイプスコープになっています(該当箇所)。

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

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

工夫2: RestClientbaseUrlを設定済みにしておく

MockRestServiceServerを利用したテストでは、URLはドメインより後の部分のみ指定する必要があります。

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

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

MockRestServiceServerを利用したテスト

テスト用のBean定義

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を上書きすることができます。この上書きを有効化するためには、以下の設定も必要です。

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

テストクラスの作成

HelloClientTest.java
@SpringBootTest(classes = {
        RestClientSampleApplication.class,
        TestConfig.class
})
class HelloClientTest {

    @Autowired
    HelloClient helloClient;

    @Autowired
    MockRestServiceServer server;
    ...
}

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

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

テストの作成

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に、どんなリクエストメソッドでリクエストすると、どんなステータスコードとどんなレスポンスボディが返るかを設定します。

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

複数リクエストへの対応

5
5
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
5
5