Framework & Library/Spring

[Java /Spring] SpringBoot Test / TestRestTemplate

마볼링 2023. 8. 8. 17:28
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 이다.

 

 

 

이러한 방식으로 테스트코드를 짜면서 개발하면 더 발전된 코드를 작성할 수 있을 것 같다.