본문 바로가기

스터디/스프링

MockRestServiceServer를 이용해 RestTemplate Test 하기

RestTemplate를 처음 사용하면서 Spring 의존성 없이 테스트 할 수 있도록 구조를 설계하여 테스트 코드를 작성한 방법에 대해 공유하고자 합니다.

사용된 외부 API

리그오브레전드를 운영하는 Riot에서 제공하는 API를 사용하였습니다.

 

구조 설계

먼저 RestTemplate을 한번 감싸서 사용하는 프로젝트만의 RestTemplate의 기능을 만들어 둡니다.
아래 코드에서는 findSummonerByName(String name)이 되겠습니다.

public class SummonerRestTemplate {
    private static final String SUMMONER_V4_FIND_BY_NAME_URL = "/lol/summoner/v4/summoners/by-name/{summonerName}";

    private final RestTemplate restTemplate;

    public SummonerRestTemplate(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public SummonerDto findSummonerByName(String name) {
        return restTemplate.getForObject(SUMMONER_V4_FIND_BY_NAME_URL, SummonerDto.class, name);
    }

}

// 통신을 통해 받을 객체
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SummonerDto {
    @JsonAlias("id")
    private String summonerId;
    private String accountId;
    private String name;

    @Builder
    public SummonerDto(String summonerId, String accountId, String name) {
        this.summonerId = summonerId;
        this.accountId = accountId;
        this.name = name;
    }
}

 

이제 SummonerRestTemplate에서 사용 할 RestTemplate을 위해 필요한 공통적인 설정을 위한
RestTemplateBuilder를 만들어 둡니다.
바로 .build() 명령어로 RestTemplate을 반환하는게 아닌 RestTemplateBuilder를 반환하는 이유는 가장 공통적인 설정만 하고 다른 곳에서 입맛에 맞게 나머지 설정을 할 수 있게 하기위해서 입니다. (ex.READ_TIME_OUT)

public class RiotRestTemplateBuilder {
    private static final String HOST = "https://kr.api.riotgames.com";

    public static RestTemplateBuilder get(RiotProperties riotProperties) {
        return new RestTemplateBuilder()
                .rootUri(HOST)
                .additionalInterceptors(new RiotTokenInterceptor(riotProperties))
                .errorHandler(new RiotErrorHandler());
    }
}

RiotPerperties는 Riot API와 통신할때 인증을 위한 토큰입니다. 
RiotPerperties, Interceptor 와 ErrorHandler의 자세한 구현 내용은 중요하지 않아 넘어가겠습니다.

위와 같이 Interceptor ErrorHandler와 같은 공통적인 설정만 해주도록 합시다.

이제 우리가 만든 SummonerRestTemplate를 사용하는 클라이언트를 위해 Bean으로 등록을 해주도록 합니다.

@Configuration
@RequiredArgsConstructor
public class SummonerRestTemplateConfiguration {

    private static final Duration READ_TIME = Duration.ofSeconds(30);
    private static final Duration CONN_TIME = Duration.ofSeconds(30);

    private final RiotProperties riotProperties;

    @Bean
    public SummonerRestTemplate summonerRestTemplate() {
        RestTemplate restTemplate = RiotRestTemplateBuilder.get(riotProperties)
                .setReadTimeout(READ_TIME)
                .setConnectTimeout(CONN_TIME)
                .build();
        return new SummonerRestTemplate(restTemplate);
    }
}

 

이제 모든 준비가 끝났으니 실제 통신이 제대로 동작하는지 테스트를 작성합니다.
테스트는 Junit5, assertJ 를 사용했습니다.

@SpringBootTest
@ExtendWith(SpringExtension.class)
class SummonerRestTemplateTest {

    @Autowired
    private SummonerRestTemplate summonerRestTemplate;

    @DisplayName("이름으로 사용자 찾기")
    @Test
    void findSummonerByName() {
        //given
        String name = "이상한새기";

        //when
        SummonerDto summonerDto = summonerRestTemplate.findSummonerByName(name);

        //then
        assertThat(summonerDto.getName()).isEqualTo(name);
    }

}
정상적으로 테스트가 통과하는 것을 확인할 수 있습니다.
image

 

이렇게 실제 통신을 하는 테스트만 만들어 두면 문제점이 있습니다.

  • API 통신에 요금이 발생한다면 매번 과금이 될 수 있다.
  • 통신이 불가능한 상황(ex.인터넷 연결 X)이라면 테스트를 할 수 없다.
  • 스프링이 올라간다음 테스트를 진행하기 때문에 속도가 느리다.

 

따라서, 우리가 만든 SummonerRestTemplate가 어떠한 동작을 하는지 실제 통신을 하지 않고 빠르게 테스트 코드를 통해 확인할 수 있도록 만들어야합니다.

 

MockRestServiceServer

MockRestServiceServer는 RestTemplate를 테스트 하기 위한 Spring에서 제공하는 테스트 라이브러리 입니다.
따라서 RestTemplate의 행동을 Mock 할 수 있는 기능을 제공합니다.
또한, Spring context를 띄워 테스트하는 것이 아니라 Mock server를 통해 테스트 하고 자바 코드로만 이루어져 있기 때문에 테스트 속도가 굉장히 빠릅니다!

RestTemplate 의 ClientHttpRequestFactory에 SimpleClientHttpRequestFactory 이 아닌
MockClientHttpRequestFactory 가 주입됩니다.

 

MockRestServiceServer에게 Mock할 RestTemplate 객체를 넘겨주고 넘겨준 RestTemplate에게 특정 요청에 대한 행동을 지정해주도록 해야합니다.


먼저 MockRestServiceServerRestTemplate를 테스트가 실행 될 때마다 새롭게 생성되도록 합니다.

class MajorSummonerRestTemplateMockTest {

    private MajorSummonerRestTemplate summonerRestTemplate;

    private MockRestServiceServer mockServer;

    @BeforeEach
    void setUp() {
        RestTemplate restTemplate = RiotRestTemplateBuilder.get(new RiotProperties()).build();
        summonerRestTemplate = new SummonerRestTemplate(restTemplate);
        mockServer = MockRestServiceServer.createServer(restTemplate);
    }

}

 

위에서 만들어 두었던 RiotRestTemplateBuilder를 통해 Mock할 RestTemplate를 만들어 줍니다.
그리고 우리가 테스트하기 위한 SummonerRestTemplate와 MockServer에 주입해주도록 합니다.
이로 인해 우리가 테스트 하는 SummonerRestTemplate에서 동작하는 기능은 mockServer에서 정의한 대로 동작을 하게 됩니다.

이제 mockServer에서 특정 요청에 대한 행동을 지정해 두도록 합니다.

this.mockServer.expect(requestTo(SUMMONER_BY_NAME_URL + encodedName))
                .andRespond(withSuccess(expectBody, MediaType.APPLICATION_JSON));

요청 주소(requestTo)가 SUMMONER_BY_NAME_URL + encodedName인 곳에 대한 응답(andRespond)으로
성공(withSuccess)과 함께 응답 값(expectBody, MediaType.APPLICATION_JSON)을 내려주도록 지정해 둡니다.

그리고 단언문인 assert를 호출하기 반드시 verify() 메소드를 호출하도록 하여 원하는 응답이 내려온지 먼저 확인을 합니다.
verify 메소드는 mockServer에서 사용되는 RestTemplate가 우리가 기대한 대로 (.expect(), .andExpect()) 동작했는지에 대해 확인합니다.

this.mockServer.verify();

 

완성된 테스트 코드는 다음과 같습니다.

class SummonerRestTemplateMockTest {
    private static final String SUMMONER_BY_NAME_URL = "https://kr.api.riotgames.com/lol/summoner/v4/summoners/by-name/";

    private SummonerRestTemplate summonerRestTemplate;

    private MockRestServiceServer mockServer;


    @BeforeEach
    void setUp() {
        RestTemplate restTemplate = RiotRestTemplateBuilder.get(new RiotProperties()).build();
        summonerRestTemplate = new SummonerRestTemplate(restTemplate);
        mockServer = MockRestServiceServer.createServer(restTemplate);
    }

    @DisplayName("이름으로 사용자 찾기")
    @Test
    void findSummonerByName() {
        //given
        String name = "이상한새기";
        String encodedName = UriEncoder.encode(name);

        String expectBody = "{\n" +
                "    \"profileIconId\": 2095,\n" +
                "    \"name\": \"이상한새기\",\n" +
                "    \"puuid\": \"0unup92CswoD3CGo6qwydHzATD4pePmpi3XhZA-kX2urduks6nJke6nlnSpmJn0hEUPgHzuo0d5Tgg\",\n" +
                "    \"summonerLevel\": 98,\n" +
                "    \"accountId\": \"w94qxPIxhJ2ALZoRItVSwyN6R-CNMXOE1VJwesmrZdAv\",\n" +
                "    \"id\": \"zN1v1n2XlkIY9cYKj9XydSSKItQNRtDLVdJHEWIkVhN5fQ\",\n" +
                "    \"revisionDate\": 1570881947000\n" +
                "}";

        this.mockServer.expect(requestTo(SUMMONER_BY_NAME_URL + encodedName))
                .andRespond(withSuccess(expectBody, MediaType.APPLICATION_JSON));

        //when
        SummonerDto summonerDto = summonerRestTemplate.findSummonerByName(name);

        //then
        this.mockServer.verify();
        assertThat(summonerDto.getName()).isEqualTo(name);
    }
}
image
Spring context를 띄우지 않고 정상적으로 통과합니다.

 

이어서 RestTemplate에 미리 등록한 ErrorHanlder에서 발생하는 exception을 테스트하기 위해 통신 실패에 따른 테스트를 작성해 보도록 하겠습니다.

@DisplayName("이름으로 사용자 찾기 실패")
@Test
public void findSummonerByName2() {
    //given
    String summonerName = "이상한새기";
    String encodedName = UriEncoder.encode(summonerName);

    String exceptBody = "{\n" +
            "    \"status\": {\n" +
            "        \"status_code\": 400,\n" +
            "        \"message\": \"BadRequest\"\n" +
            "    }\n" +
            "}";

	//when
    this.mockServer.expect(requestTo(SUMMONER_BY_NAME_URL + encodedName))
            .andRespond(withBadRequest()
                    .body(exceptBody)
                    .contentType(MediaType.APPLICATION_JSON));

    
    //then
    this.mockServer.verify();
    assertThatThrownBy(() -> summonerRestTemplate.findSummonerByName(summonerName))
            .isInstanceOf(NoSuchElementException.class);
}
image
실패 테스트도 정상적으로 통과하였습니다.

 

바뀐 부분은 andRespond() 블럭 내 withBadRequest가 있고 따로 body, contentType을 지정하는 것임을 확인 할 수 있습니다.
만약 BadRequest가 아닌 다른 status code를 테스트 하고 싶다면 withStatus를 사용하면 됩니다.

 

구조 개선

현재 상태로는 한 가지 문제점이 있습니다.
바로 SummonerRestTemplate를 사용하는 쪽(ex. 다른 모듈)에서 테스트 코드를 작성할 경우 실제 통신이 이루어져야 하기 때문에 Spring context가 반드시 떠야한다는 점 입니다.

이를 해결하기 위해 SummonerRestTemplateInterface로 변경하고  
SummonerRestTemplateConfiguration 코드를 변경해 주도록 합니다.

아래와 같이 profile 별로 실행되는 SummonerRestTemplate를 만들어 주도록 합시다.

public interface SummonerRestTemplate {
    SummonerDto findSummonerByName(String name);
}

public class MajorSummonerRestTemplate implements SummonerRestTemplate {
    private static final String SUMMONER_V4_FIND_BY_NAME_URL = "/lol/summoner/v4/summoners/by-name/{summonerName}";

    private final RestTemplate restTemplate;

    public MajorSummonerRestTemplate(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Override
    public SummonerDto findSummonerByName(String name) {
        return restTemplate.getForObject(SUMMONER_V4_FIND_BY_NAME_URL, SummonerDto.class, name);
    }

}

public class StubSummonerRestTemplate implements SummonerRestTemplate {
    @Override
    public SummonerDto findSummonerByName(String name) {
        return SummonerDto.builder()
                .name(name)
                .accountId("stubAccountId")
                .summonerId("stubSummonerId")
                .build();
    }
}
image
변경된 구조

 

StubSummonerRestTemplate는 통신을 하지 않고 항상 고정된 SummonerDto를 반환도록 합니다.

이제 Profile에 따라 다른 Bean을 띄우도록 SummonerRestTemplateConfiguration을 수정하도록 합니다.


@Profile 어노테이션을 이용하여 active된 profile 별로 Bean이 생성되게 됩니다.

public class SummonerRestTemplateConfiguration {

    private static final Duration READ_TIME = Duration.ofSeconds(30);
    private static final Duration CONN_TIME = Duration.ofSeconds(30);

    @Profile("major")
    @Configuration
    @RequiredArgsConstructor
    public static class MajorConfig {
        private final RiotProperties riotProperties;

        @Bean
        public SummonerRestTemplate summonerRestTemplate() {
            RestTemplate restTemplate = RiotRestTemplateBuilder.get(riotProperties)
                    .setReadTimeout(READ_TIME)
                    .setConnectTimeout(CONN_TIME)
                    .build();
            return new MajorSummonerRestTemplate(restTemplate);
        }
    }

    @Profile("local")
    @Configuration
    public static class LocalConfig {
        @Bean
        public SummonerRestTemplate summonerRestTemplate() {
            return new StubSummonerRestTemplate();
        }
    }

}

이제 profile active의 상태가 major라면 MajorConfiglocal라면 LocalConfig가 실행되어 상황에 알맞게 SummonerRestTemplate Bean이 생성되게 되었습니다.

 

다른 Http Method 테스트

기본적으로 아무런 설정도 안한다면 mockServer는 Http method가 GET으로 온다고 기대합니다.
POST와 같은 다른 method를 테스트하고 싶다면 다음과 같이 .andExpect(method())를 사용하면 됩니다.

mockServer.expect(requestTo("http://yourURL"))
                .andExpect(method(HttpMethod.POST));

 

의존성 분리

Spring 의존성 없이 테스트 코드를 작성하기 위해서는 커스텀하게 사용하는 SummonerRestTeamplate에서 사용하는 RestTemplate가 외부에서 주입받는 형식으로 의존성을 바깥으로 분리해야 했습니다.
처음 코드를 작성할때는 RestTemplate를 내부적으로 생성해서 사용하였기 때문에 Spring 이 반드시 필요한 테스트 밖에 작성하지 못 했습니다.

테스트 하기 어렵고 원하는대로 테스트가 되지 않는다면 구조가 잘못 되었는지 의심하는 생각을 해야 할 것 같습니다.