RestClientを使って楽にテストする (original) (raw)

きっかけ

web apiを作ったのだが、ユニットテストで毎回難儀していたので、楽にテストが書けると聞いたRestClientを使うことに。

実装

今回は、RestClientを生成するConfigクラスと、実際にAPIとして使う場所を分けてる。
あと、余談だが、使っているweb apiは、NewsAPIっていう海外のニュース情報が取得できるサイト。
無料アカウントなら、アクセスできる回数に制限があるけど自由に使えるので、情報収集がてら使ってみることに。

前提

やるにしても環境情報載せないとね。。。
一部抜粋なので、適時読み替えて。
必要そうなものだけ載せてる。※抜けてたらすまぬ

plugins { id 'org.springframework.boot' version '3.3.3' }

repositories { mavenCentral() gradlePluginPortal() }

allprojects { ext { springVersion = "3.3.3" } }

dependencies { implementation "org.springframework.boot:spring-boot-starter-web:$springVersion" implementation 'org.apache.httpcomponents.client5:httpclient5' }

RestClientを生成しているConfigクラス。

参考サイトを元に、とりあえず真似てみる。
最低限であれば、HttpClientBuilder.create() あたりからメソッドの最後まであればいいと思う。

package com.galewings.config;

import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestClient;

import java.util.concurrent.TimeUnit;

@Configuration public class NewsApiConfig { @Value("${newsapi.url}") private String baseUrl;

@Bean
RestClient customRestClient() {
    var connectionConfig = ConnectionConfig.custom()
            // TTLを設定
            .setTimeToLive(TimeValue.of(59, TimeUnit.SECONDS))
            // コネクションタイムアウト値を設定
            .setConnectTimeout(Timeout.of(1, TimeUnit.SECONDS))
            // ソケットタイムアウト値を設定(レスポンスタイムアウトと同義)
            .setSocketTimeout(Timeout.of(5, TimeUnit.SECONDS))
            .build();

    // PoolingHttpClientConnectionManagerを使うことでコネクションがプールされて
    // リクエストごとにコネクションを確立する必要がなくなる
    var connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
            .setDefaultConnectionConfig(connectionConfig)
            // 全ルート合算の最大接続数
            .setMaxConnTotal(100)
            // ルート(基本的にはドメイン)ごとの最大接続数
            // !!! デフォルトが「5」で高負荷には耐えられない設定値なので注意 !!!
            .setMaxConnPerRoute(100)
            .build();

    var httpClient = HttpClientBuilder.create()
            .setConnectionManager(connectionManager)
            .build();

    var requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
    return RestClient.builder()
            .baseUrl(baseUrl)
            .requestFactory(requestFactory)
            .defaultStatusHandler(new DefaultResponseErrorHandler())
            .build();
}

}

実際にRestClientを利用しているServiceの実装

簡単に説明すると、NewsApiConfig で生成されたRestClient のインスタンスをDIしてやって、topHeadlinesメソッドで使ってる。※restClient.get()~~~のあたり ※一部抜粋なので、これをコピペしただけでは動かないので注意

public class NewsApiService { @Value("${newsapi.api-key}") private String apiKey;

private final RestClient restClient;

@Autowired
public NewsApiService(RestClient restClient) {
    this.restClient = restClient;
}

public NewsApiResponseDto topHeadlines(OptionalRequestParam optionalRequestParam) throws URISyntaxException, IllegalAccessException, IOException {
    if (Objects.isNull(apiKey)) {
        throw new GaleWingsSystemException("newsapi.api-key not found. add .env file");
    }

    Map<String, String> param = optionalRequestParam.queryParamMap();
    param.put("apiKey", apiKey);

    String paramStr = generateQueryParamStr(param);
    String response = restClient.get().uri("/top-headlines?" + paramStr).retrieve().body(String.class);
    ObjectMapper om = new ObjectMapper();

    return om.readValue(response, NewsApiResponseDto.class);
}

}

今まで、URL組み立ててコネクション確立して、レスポンスのステータスコード見て~みたいなことをしていたけど、よしなにRestClientがやってくれるので、あんまり気にしなくていい。
ボディ部の文字列表現を受け取って、Jacksonでオブジェクトにマッピングしている。

だいぶ楽に書けた。

ユニットテスト

ここで楽するためにやってきた!

package com.galewings.service;

import com.galewings.dto.newsapi.request.OptionalRequestParam; import com.galewings.dto.newsapi.response.NewsApiResponseDto; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.http.HttpMethod; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient;

import java.io.IOException; import java.net.URISyntaxException;

import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

class NewsApiServiceTest {

@Test
void testTopHeadlines2() throws URISyntaxException, IOException, IllegalAccessException {
    String apiKey = "test";

    var restClientBuilder = RestClient.builder();
    MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
    mockServer.expect(requestTo("/top-headlines?country=jp&apiKey=" + apiKey + "&category=test"))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess().body("{\"status\":\"ok\",\"totalResults\":4145,\"articles\":[{\"source\":{\"id\":null,\"name\":\"ETFDailyNews\"},\"author\":\"MarketBeatNews\",\"title\":\"test\",\"description\":\"test description\",\"url\":\"https://localhost\",\"urlToImage\": null,\"publishedAt\":\"2024-09-17T08:44:33Z\",\"content\":\"test\"}]}"));

    var restClient = restClientBuilder.build();
    newsApiService = new NewsApiService(restClient);
    ReflectionTestUtils.setField(newsApiService, "apiKey", apiKey);
    OptionalRequestParam param = new OptionalRequestParam();
    param.country = "jp";
    param.category = "test";
    NewsApiResponseDto result = newsApiService.topHeadlines(param);
    Assertions.assertNotNull(result);
    Assertions.assertEquals("ok", result.getStatus());
    Assertions.assertEquals(4145L, result.getTotalResults());

}

}

注目するところは、MockRestServiceServer のあたり。
コイツが、特定リクエストのレスポンスを返してくれるので、実際にサーバーにアクセスしたりすることなく、実装箇所のテストができるってわけ。

これ使う前は、URLのコネクション確率をモック化したりしてたけど、トラップが大量にあるせいで、前髪がかなり後退した。

実際のサーバーにアクセスせず、モックが簡単に書けるのが、かなりいい。
使わない前の状態が、かなり面倒くさかったから、かなり便利になった。

雑記・感想など

調査する段階で、RestTemplateとか出てきたけど、一番新しいのがRestClientらしく、それが推奨らしい。情報がRestTemplateばかり出てくるので悩んだが、どうせならということでRestClientにした。

Springのバージョン違いでRestClientがライブラリになくてかなり迷った。。。
サンプルコード載せるときは、バージョン情報は必須で書くべきだと思った。
あと、import文も記載が欲しい。どのライブラリのやつ使ってるのかわからないから、それが判断できるものがないと、たくさんライブラリ利用しているプロジェクトだと、どれをimportすればいいのか悩む。
自分も書くときは気をつけないといかんなと思った。

関連リンク

News API – Search News and Blog Articles on the Web

REST Clients :: Spring Framework

Spring Framework 6.1 から追加された RestClient を試してみる #Java - Qiita

【SpringBoot 3.2で登場!】RestClientの使い方 | ひらべーブログ