Spring혹은 SpringBoot를 이용하여 RestApi를 설계할 때, 상태코드를 같이 전달하기 위해
ResponseEntity를 사용하는 경우가 많다.
이 문서에서는 이러한 ResponseEntity를 사용하는 RestApi Test환경에 유용하게 사용되는
TestRestTemplate에 대해 알아보자.
📕 TestRestTemplate
- REST 방식으로 개발한 API의 Test를 최적화 하기 위해 만들어진 클래스이다.
- HTTP 요청 후 데이터를 응답 받을 수 있는 템플릿 객체이며 ResponseEntity와 함께 자주 사용된다.
- Header와 Content-Type 등을 설정하여 API를 호출 할 수있다.
📗 예제 1. postForEntity
JWT을 사용하는 로그인 Api의 테스트이다.
[AuthController]
@PostMapping("/login")
public ResponseEntity loginMember(@RequestBody @Valid MemberLoginDto memberloginDto) {
Member member = memberService.login(memberloginDto);
String token = tokenProvider.createToken(member);
MemberResponseDto responseDto = MemberResponseDto.builder()
.username(member.getUsername())
.token(token)
.build();
return new ResponseEntity(responseDto, HttpStatus.OK);
}
MemberLoginDto 형식으로 데이터를 받아 로그인을 진행하고,
로그인이 완료되면 JWT를 리턴해주는 예제이다.
[AuthControllerTest]
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Rollback(value = false)
class AuthApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate testRestTemplate;
@Autowired
MemberService memberService;
@Autowired
TokenProvider tokenProvider;
@Test
@DisplayName("로그인 성공")
void login() {
//given
String username = "test";
String password = "1234";
MemberLoginDto memberLoginDto = MemberLoginDto.builder()
.username(username)
.password(password)
.build();
String url = "http://localhost:"+port+"/api/auth/login";
//when
ResponseEntity<MemberResponseDto> responseEntity = testRestTemplate.postForEntity(url, memberLoginDto, MemberResponseDto.class);
//then
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
String validToken = tokenProvider.validToken(responseEntity.getBody().getToken()); // 토큰 검증
Assertions.assertThat(responseEntity.getBody().getUsername()).isEqualTo(validToken);
}
}
- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
- SpringBoot 애플리케이션의 통합 테스트 수행
- webEnvironment - 어떤 종류의 웹 환경에서 테스트를 실행할 것인가.
- RANDOM_PORT - 테스트마다 랜덤한 포트 번호가 할당되어 충돌을 방지하고 병렬로 여러 테스트를 실행 할 수 있다.
- 테스트를 실행할 때 어떠한 포트를 SpringBootTest에서 사용중인지 모르기 때문에,
TestRestTemplate를 사용하려면 반드시 설정해야한다.
- Given - When - Then
- BDD(행위 주도 개발)에서 주로 사용되는 패턴
- 테스트 대상의 환경을 어떠한 상태에 두고(Given)
- 어떤 행동을 요구했을 때(When)
- 기대하는 결과를 돌려받아야 한다.(Then)
- Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK) : 상태코드 일치
- String validToken = tokenProvider.validToken(responseEntity.getBody().getToken());
Assertions.assertThat(responseEntity.getBody().getUsername()).isEqualTo(validToken);
-> 해당 토큰이 이 서버에서 사용하는 토큰이라면 username을 리턴한다.
📗 예제 2. Header에 추가 exchange
(코드가 긴 부분 생략)
[MemberApiController]
@RequestMapping("/api/member")
...
@GetMapping("/characterList")
public ResponseEntity getCharacterList(@AuthenticationPrincipal String username) {
try {
// username 으로 연결된 캐릭터리스트 호출
List<Character> characterList = memberService.findMember(username).getCharacters();
...
// 결과 출력
CharacterListResponeDto charactersReturnDto = CharacterListResponeDto.builder()
.characters(characterResponseDtoList)
.sumDayContentProfit(sum)
.sortedDayContentProfitDtoList(sortedDayContentProfit)
.build();
return new ResponseEntity<>(charactersReturnDto, HttpStatus.OK);
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
JWT받은 username에 등록된 캐릭터 리스트를 호출하는 Api 이다.
[MemberApiControllerTest]
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
class MemberApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate testRestTemplate;
@Autowired
TokenProvider tokenProvider;
@Autowired
CharacterService characterService;
@Autowired
MemberService memberService;
String username = "test";
String token;
@BeforeEach
void login() {
String password = "1234";
MemberLoginDto memberLoginDto = MemberLoginDto.builder()
.username(username)
.password(password)
.build();
String url = "http://localhost:"+port+"/api/auth/login";
ResponseEntity<MemberResponseDto> responseEntity = testRestTemplate.postForEntity(url, memberLoginDto, MemberResponseDto.class);
token = responseEntity.getBody().getToken();
}
@Test
@DisplayName("캐릭터 리스트 호출 성공")
void getCharacterList() {
//given
String url = "http://localhost:"+port+"/api/member/characterList";
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Content-Type", "application/json");
headers.set("Authorization", "Bearer " + token);
//when
ResponseEntity<CharacterListResponeDto> responseEntity = new TestRestTemplate().exchange(url, HttpMethod.GET,
new HttpEntity<Object>(headers), CharacterListResponeDto.class);
//then
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
List<Character> characters = memberService.findMember(username).getCharacters();
Assertions.assertThat(responseEntity.getBody().getCharacters().size()).isEqualTo(characters.size());
System.out.println("responseEntity.getBody() = " + responseEntity.getBody());
}
}
- @BeforeEach
- 각 테스트 진행전에 실행되는 메서드이다.
- 이 테스트에서는 로그인하여 받아온 Token을 저장한다.
- MultiValueMap
- Spring Framwork에서 제공하는 인터페이스로, 여러 개의 값을 하나의 키에 연결하여 저장할 수 있는 데이터 구조이다.
- LinkedMultiValueMap 은 이 인터페이스의 구현 중 하나로, 순서를 유지하는 링크드 리스트와 같은 형태로 값을 저장하며, 같은 키에 여러 개의 값을 연결 할 수 있다.
- set혹은 add를 이용해서 HTTP 요청 헤더를 구현할 수 있다.
- 여기서는 아까 로그인으로 가져온 JWT의 token값을 저장한다.
📗 예제 3. JWT 인증 실패 (403 에러)
로그인하지 않은 상태라 JWT가 없으면 어떻게 될까?
[MemberApiControllerTest]
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Rollback(value = false)
class MemberApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate testRestTemplate;
@Autowired
TokenProvider tokenProvider;
String username = "test";
String token;
@BeforeEach
void login() {
//PATCH 오류 해결
testRestTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory());
String password = "1234";
MemberLoginDto memberLoginDto = MemberLoginDto.builder()
.username(username)
.password(password)
.build();
String url = "http://localhost:"+port+"/api/auth/login";
ResponseEntity<MemberResponseDto> responseEntity = testRestTemplate.postForEntity(url, memberLoginDto, MemberResponseDto.class);
token = responseEntity.getBody().getToken();
}
@Test
@DisplayName("캐릭터 리스트 호출 실패 - Token 미인증 (403 에러)")
void getCharacterListTokenError() {
//given
String url = "http://localhost:"+port+"/api/member/characterList";
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Content-Type", "application/json");
//when
ResponseEntity<ErrorResponse> responseEntity = testRestTemplate.exchange(url, HttpMethod.GET,
new HttpEntity<Object>(headers), ErrorResponse.class);
//then
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); // 403 에러
}
}
- testRestTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory());
- 기본적으로 RestTemplate는 JDK의 HttpURLConnection을 기반으로 동작한다.
- 근데 이 안에 PATCH 요청이 없다
- 따라서 PATCH 요청을 사용하기 위해서는 이러한 TestRestTemplate를 커스텀 해줘야한다.
-> apache httpclient 주입
[build.gradle]
implementation 'org.apache.httpcomponents:httpclient:4.5.13'
//when
ResponseEntity<ErrorResponse> responseEntity = testRestTemplate.exchange(url, HttpMethod.GET,
new HttpEntity<Object>(headers), ErrorResponse.class);
-> Error를 리턴하기 때문에, responseEntity의 Body는 ErrorResponse.class 이다.
Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); // 403 에러
-> 상태코드가 200이 아닌 403이기 때문에 기대하는 값은 FORBIDDEN 이다.
이러한 방식으로 테스트코드를 짜면서 개발하면 더 발전된 코드를 작성할 수 있을 것 같다.