エンターテイメント!!

遊戯王好きのJavaエンジニアのブログ。バーニングソウルを会得する特訓中。

RestClientを使って楽にテストする

きっかけ

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の使い方 | ひらべーブログ