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);
}
}
이렇게 실제 통신을 하는 테스트만 만들어 두면 문제점이 있습니다.
- API 통신에 요금이 발생한다면 매번 과금이 될 수 있다.
- 통신이 불가능한 상황(ex.인터넷 연결 X)이라면 테스트를 할 수 없다.
- 스프링이 올라간다음 테스트를 진행하기 때문에 속도가 느리다.
따라서, 우리가 만든 SummonerRestTemplate
가 어떠한 동작을 하는지 실제 통신을 하지 않고 빠르게 테스트 코드를 통해 확인할 수 있도록 만들어야합니다.
MockRestServiceServer
MockRestServiceServer
는 RestTemplate를 테스트 하기 위한 Spring에서 제공하는 테스트 라이브러리 입니다.
따라서 RestTemplate의 행동을 Mock 할 수 있는 기능을 제공합니다.
또한, Spring context를 띄워 테스트하는 것이 아니라 Mock server를 통해 테스트 하고 자바 코드로만 이루어져 있기 때문에 테스트 속도가 굉장히 빠릅니다!
RestTemplate 의 ClientHttpRequestFactory에 SimpleClientHttpRequestFactory 이 아닌
MockClientHttpRequestFactory 가 주입됩니다.
MockRestServiceServer
에게 Mock할 RestTemplate 객체를 넘겨주고 넘겨준 RestTemplate에게 특정 요청에 대한 행동을 지정해주도록 해야합니다.
먼저 MockRestServiceServer
와 RestTemplate
를 테스트가 실행 될 때마다 새롭게 생성되도록 합니다.
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);
}
}
이어서 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);
}
바뀐 부분은 andRespond()
블럭 내 withBadRequest
가 있고 따로 body
, contentType
을 지정하는 것임을 확인 할 수 있습니다.
만약 BadRequest가 아닌 다른 status code
를 테스트 하고 싶다면 withStatus
를 사용하면 됩니다.
구조 개선
현재 상태로는 한 가지 문제점이 있습니다.
바로 SummonerRestTemplate
를 사용하는 쪽(ex. 다른 모듈)에서 테스트 코드를 작성할 경우 실제 통신이 이루어져야 하기 때문에 Spring context가 반드시 떠야한다는 점 입니다.
이를 해결하기 위해 SummonerRestTemplate
를 Interface
로 변경하고 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();
}
}
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
라면 MajorConfig
가 local
라면 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 이 반드시 필요한 테스트 밖에 작성하지 못 했습니다.
테스트 하기 어렵고 원하는대로 테스트가 되지 않는다면 구조가 잘못 되었는지 의심하는 생각을 해야 할 것 같습니다.
'스터디 > 스프링' 카테고리의 다른 글
Spring Data JDBC - 엔티티 생성편 (0) | 2020.05.05 |
---|---|
[미션] Producer - Consumer - 1단계 (0) | 2020.04.27 |
@Valid 어노테이션을 이용한 객체 검증 (0) | 2020.04.05 |
GCP Cloud Storage 객체 업로드, 다운로드 (0) | 2020.03.01 |
SpringBoot + Vue 하나의 포트로 개발해보기 (0) | 2020.01.28 |