やりたいこと
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
(参考記事)で使い分ける感じになります。
@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のタイムゾーンと日付フォーマット形式を指定します。
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
@Component
public class HelloClient {
private final RestClient restClient;
public HelloClient(RestClient restClient) {
this.restClient = restClient;
}
...
}
GETリクエストの送信
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();
}
// レスポンスされる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リクエストの送信
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";
}
// リクエストボディのJSONに変換されるクラス
public record HelloRequest(String message) {
}
.post()
でPOSTリクエストであることを示し、.uri()
でURLを、.body()
でリクエストボディを指定します。
.retrieve()
以降にレスポンスに関する記述を行います。レスポンスボディが無い場合は.toBodilessEntity()
とすると、ResponseEntity<Void>
が戻り値になります。
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.Builder
Beanが使われるので、うまくテストができません。
なので、RestClient.Builder
のBeanをシングルトンにするために、あえて自前でBean定義していたのです。
工夫2: RestClient
にbaseUrl
を設定済みにしておく
MockRestServiceServer
を利用したテストでは、URLはドメインより後の部分のみ指定する必要があります。
// OKな例
restClient.get().uri("/api/hello")...
// NGな例
restClient.get().uri("http://localhost:8080/api/hello")...
なので、RestClient
にbaseUrl
を設定済みにしておきましょう。
MockRestServiceServer
を利用したテスト
テスト用のBean定義
@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を上書きすることができます。この上書きを有効化するためには、以下の設定も必要です。
spring.main.allow-bean-definition-overriding=true
テストクラスの作成
@SpringBootTest(classes = {
RestClientSampleApplication.class,
TestConfig.class
})
class HelloClientTest {
@Autowired
HelloClient helloClient;
@Autowired
MockRestServiceServer server;
...
}
MockRestServiceServer
と、テスト対象のHelloClient
をDIします。
@SpringBootTest
のclasses
要素にはTestConfig
に加えて、@SpringBootApplication
が付いたクラスを指定します。
テストの作成
@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に、どんなリクエストメソッドでリクエストすると、どんなステータスコードとどんなレスポンスボディが返るかを設定します。
その後、テスト対象メソッドの実行とアサーションを行います。
複数リクエストへの対応