프로그래머스 데브코스 백엔드 4기 코스에서 과제를 수행하던 중, RestController의 테스트 코드를 짜게 되었다.
일련의 시행착오도 거치고, 잘못된 지식도 있었다.
그리고 세상에서 제일 잔인한게 '아.. 이거 해본거 같은데 기억이 안나네..' 이다.. 기억이 휘발되어 이러한 반복을 방지하기 위해 글로 남긴다!
목차
1. 스프링에서 RestController 테스트시 주로 사용되는 어노테이션
2. 나는 어떤걸 썼느냐?
3. 리팩토링을 하며 거쳤던 시행착오 (feat. 예외에 대한 테스트)
4. @WebMvcTest 사용시, 간단한 테스트의 사용법
5. 다섯 줄 요약
들어가기 앞서, 스프링에서 RestController 테스트시 주로 사용되는 어노테이션
1. @WebMvcTest
- 목적 : 스프링 MVC 컨트롤러를 테스트하기 위한 어노테이션이다.
- 특징
- 웹 애플리케이션의 컨트롤러 레이어를 테스트하는 데 특화되어 있다.
- 애플리케이션의 다른 레이어 (Service, Repository)를 불러오지 않으며, 컨트롤러와 관련된 Spring MVC 구성 요소만 로드한다.
- 컨트롤러와 관련된 빈들을 주입하여 테스트할 수 있는 환경을 제공한다.웹 서버를 시작하지 않고도 웹 요청과 응답을 테스트 할 수 있다.
- 컨트롤러와 관련된 로직을 직접 테스트할 수 있는 단위 테스트의 범위에 해당한다.
- 장점 : 이 어노테이션은 컨트롤러 Bean만 로드하여 테스트하므로 다른 컴포넌트는 로드하지 않는다.
- 그래서 다른 레이어의 Bean들을 주입되지 않기에, 테스트의 성격에 맞게 가볍고 빠른 테스트 환경을 제공한다.
- 컨트롤러에 집중하여 테스트할 수 있다.
2. @AutoConfigureMockMvc + @SpringBootTest
- 목적 : 주로 통합 테스트를 수행하기 위한 목적으로 사용된다. 이 조합은 실제 애플리케이션 컨텍스트를 로드하고 MockMvc를 자동으로 구성하여 HTTP 요청과 응답을 테스트할 수 있다.
- 특징
- 실제 애플리케이션 컨텍스트를 로드한다. @SpringBootTest를 사용하므로 모든 스프링 구성 요소들이 로드된다.
- 단위 테스트와 같이 기능을 테스트할 때보다는, 통합 테스트를 할 때 사용한다.
- 장점
- 실제 애플리케이션 컨텍스트를 로드하므로 테스트의 신뢰성이 높아진다. 애플리케이션 컨텍스트를 모두 로드하므로 실제 환경과 가장 유사한 상태에서 테르스를 수행할 수 있다!
- 단점 : 애플리케이션 컨텍스트를 로드하므로 테스트 실행 속도가 다른 테스트보다 상대적으로 느릴 수 있다.
- 모든 컴포넌트를 로드하기 때문에 테스트 범위가 넓어진다. 따라서 테스트 환경을 명확하게 구성할 필요가 있다.
그래서 나는 뭘 썼느냐?
평소 Service, Repository의 테스트등은 주로 해보아서 어느정도 익숙했지만, RestController 테스트에 대한 지식은 깊지 않았다.
그래서 어떤 강의였는지.. 책이었는지 모르겠지만 내가 본 곳에서는 위의 2번 (@AutoConfigureMockMvc + @SpringBootTest)을 사용해서 나도 같은 2번으로 테스트 코드를 늘 작성해 왔었다.
@AutoConfigureMockMvc // 위의 2번 조합
@SpringBootTest // 위의 2번 조합
class VoucherApiControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Autowired
JdbcVoucherRepository voucherRepository; // 실제 객체를 주입
@Test
@DisplayName("바우처 생성 성공")
void createVoucherSuccessTest() throws Exception {
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 10);
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isCreated())
.andDo(print());
}
@Test
@DisplayName("바우처 생성 실패 - 잘못된 금액")
void createVoucherFailTest_Amount() throws Exception {
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 1200);
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isBadRequest())
.andDo(print());
}
@Test
@DisplayName("바우처 전체 조회")
void findAllSuccessTest() throws Exception {
voucherRepository.insert(new FixedAmountVoucher(UUID.randomUUID(), 10)); // 컨트롤러 테스트에서 실제 주입받은 Repository를 사용하고 있다.
mockMvc.perform(get("/api/vouchers/list")
.contentType(APPLICATION_JSON))
.andExpect(status().isOk()).andDo(print());
}
글의 처음 부분에서 보았던 것처럼 @AutoConfigureMockMvc + @SpringBootTest을 사용했고, @AutoWired를 이용하여 다른 레이어 객체를 주입 받았다. (테스트 코드 좀 치는 분들은 예상하셨겠지만, 이미 여기서부터 테스트가 잘 슬라이스 되지 않았단 것을 알 수 있다.)
또한 '바우처 전체 조회' 테스트 에서는 컨트롤러 테스트인데 실제 주입받은 Repository의 insert 기능을 사용하고 있다.
또한 훌륭하신 멘토님께서도 내가 요청한 PR을 보시고는 저렇게 답변을 해주셨다!
RestController 테스트에서, 테스트가 잘 나뉘어지지 않은 가장 큰 이유는...
실제 객체가 생성되어서 주입되고 있으니까 !!!!!
라고 할 수 있겠다.
그래서 멘토님의 피드백처럼 오직 컨트롤러에 대해서만 테스트 할 수 있는 슬라이스 테스트로 변경해 보고자 하였다.
@WebMvcTest(VoucherApiController.class) // 테스트에 사용할 컨트롤러 설정
class VoucherApiControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean //가짜 객체인 MockBean으로 주입
VoucherService voucherService;
@Test
@DisplayName("바우처 생성 성공")
void createVoucherSuccessTest() throws Exception {
//given
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 10);
Voucher createdVoucher = new PercentDiscountVoucher(UUID.randomUUID(), 10);
//when
given(voucherService.create(createVoucherRequest))
.willReturn(new VoucherResponse(createdVoucher));
//then
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isCreated())
.andDo(print());
}
@Test
@DisplayName("바우처 생성 실패 - 잘못된 금액")
void createVoucherFailTest_Amount() throws Exception {
//given
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 2000);
//when
given(voucherService.create(createVoucherRequest))
.willThrow(new BusinessException(ErrorCode.INVALID_PERCENT_VOUCHER_AMOUNT));
//then
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isBadRequest())
.andDo(print());
}
@Test
@DisplayName("바우처 전체 조회")
void findAllSuccessTest() throws Exception {
//given
List<Voucher> vouchers = Arrays.asList(
new PercentDiscountVoucher(UUID.randomUUID(), 10),
new PercentDiscountVoucher(UUID.randomUUID(), 20)
);
//when
given(voucherService.findAll())
.willReturn(new VoucherListResponse(vouchers.stream()
.map(VoucherResponse::new)
.toList()));
//then
mockMvc.perform(get("/api/vouchers/list")
.contentType(APPLICATION_JSON))
.andExpect(status().isOk()).andDo(print());
}
바뀐 코드를 보면
1. @AutoConfigureMockMvc + @SpringBootTest -> @WebMvcTest 사용
2. 다른 레이어를 @Autowired를 이용하여 실제로 주입 -> @MockBean을 이용하여 가짜 객체로 주입
우선 이 두가지가 가장 큰 변화이다.
여기서 컨트롤러 호출시 각 API에 대하여 성공에 대한 테스트는 수월하게 되었다.
요청에 맞게 응답의 Body 메세지도 잘 적용되었다.
하지만, 잘못된 요청에 관한 예외 테스트에서 삽질을 하였다.
나 같은 경우에는, Service 레이어, 도메인 객체등에서 여러 예외 상황에 대한 대처를 준비해뒀다. (지정해둔 커스텀 예외의 에러 코드로 던져준다던지..)
ex) 바우처 amount의 퍼센트 범위가 1~99로 지정해뒀다.
하지만 그 범위의 값이 들어오지 않으면 커스텀 예외인 BusinessException이 터지고, 그것을 핸들러가 캐치해서 각 예외 유형에 대한 처리 메소드를 핸들링 해주는 구조이다.
@Getter
public abstract class Voucher {
//중략..
protected Voucher(UUID voucherId, long amount) {
validateAmount(amount);
this.voucherId = voucherId;
this.amount = amount;
}
protected abstract void validateAmount(long amount);
//중략..
}
public class PercentDiscountVoucher extends Voucher{
private static final long MIN_PERCENT = 0;
private static final long MAX_PERCENT = 99
// 중략..
@Override
protected void validateAmount(long discountAmount) { // 퍼센트 바우처의 유효성 검사
if (discountAmount <= MIN_PERCENT || discountAmount > MAX_PERCENT) {
throw new BusinessException(INVALID_PERCENT_VOUCHER_AMOUNT);
}
}
}
@Getter
public class BusinessException extends RuntimeException {
private ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> businessException(BusinessException e) {
return ErrorResponse.toResponseEntity(e.getErrorCode());
}
그래서 컨트롤러에서도 잘못된 값이 들어왔을 경우 내가 구성해둔 핸들러가 잘 동작하는지 확인이 하고 싶었다.
내가 처음 작성했다고 한 테스트 코드를 다시 보면,
@WebMvcTest(VoucherApiController.class) // 테스트에 사용할 컨트롤러 설정
class VoucherApiControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Autowired
JdbcVoucherRepository voucherRepository;
@Test
@DisplayName("바우처 생성 실패 - 잘못된 금액")
void createVoucherFailTest_Amount() throws Exception {
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 1200);
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isBadRequest()) // isBadRequest, 400 에러가 잘 나오는지!
.andDo(print());
}
요청에 필요하지만, 잘못된 데이터를 가진 객체를 만들고 mockMvc의 andExpect절에 내가 기대하는 예외를 넣어줬을 때 잘 동작이 된다.
당연하다. @AutoConfigureMockMvc + @SpringBootTest 조합을 사용했기에, 모든 Service + Respoitory등 실제 객체로 로드되었을 것이다. 그러므로 컨트롤러에서 값이 넘어 갔을 때 각 레이어에 맞는 예외 처리를 해주었을 것이다.
하지만 문제는 지금부터다.
그럼 가짜 객체 @MockBean으로 만들어준 경우는.. 예외에 대한 테스트를 어떻게 할까........를 가지고 삽질을 하고 팀원들과 상의도 했었다.
@Test
@DisplayName("바우처 생성 실패 - 잘못된 금액")
void createVoucherFailTest_Amount() throws Exception {
//given
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 2000);
//when -> then
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isBadRequest())
.andDo(print());
}
예외가 터져서 핸들링이 되길 기도하며, 기존의 형식으로 시도해 봤다. 당연히 안된다. 왜? 가짜 객체가 주입되어 있으니..
나는 400 에러를 기대했는데, 예외를 핸들링 해주지 못하고 그대로 201을 갖다 꽂는 경우를 계속 볼 수 있었다. (메세지는 바우처를 생성해줬다고 하는데 body에는 당연히 null..)
한참 삽질을 하다가 결국 알아냈다...............참 알고 나니까 허탈했지만 정말 단순하다.
willThrow ...
우선 코드로 보자.
@Test
@DisplayName("바우처 생성 실패 - 잘못된 금액")
void createVoucherFailTest_Amount() throws Exception {
//given
CreateVoucherRequest createVoucherRequest = new CreateVoucherRequest(VoucherType.PERCENT, 2000);
//when
given(voucherService.create(createVoucherRequest))
.willThrow(new BusinessException(INVALID_PERCENT_VOUCHER_AMOUNT)); // willThrow로 기대하는 예외를 넣어주면 된다..!
//then
mockMvc.perform(post("/api/vouchers/create")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createVoucherRequest)))
.andExpect(status().isBadRequest())
.andDo(print());
}
Mocktio의 given, willReturn 정도밖에 몰랐던 나는 willThrow가 있는줄 몰랐다 ㅠㅠ (무지의 무서움)
한시간 가량 삽질을 했다.. willReturn에 new BusinessException 객체를 어거지로 끼워 넣어보려고도 하고.. (당연히 될리 ㄴ)
결국 willThrow(<--본인이 기대하는 예외-->) 이런 식으로 던져주고 then 절에서 그것이 잘 동작하는지 확인하면 되었다..!
아마 기존에 알고 있던분들은 가볍게 미소를 띄며 보실수도 있지만, 감격스러운 순간이었다.
또한 테스트하고자 하는 메서드가 반환 타입이 없다면 Mockito의 doThrow를 이용하는 것도 방법이다.
@Test
@DisplayName("바우처 전체 삭제 실패 - 존재하는 바우처 없음")
void deleteAllFailTest() throws Exception {
//given -> when
doThrow(new BusinessException(NOT_FOUND_VOUCHER))
.when(voucherService).deleteAll();
//then
mockMvc.perform(delete("/api/vouchers"))
.andExpect(status().isNotFound())
.andDo(print());
}
다섯 줄 요약
- 스프링에서 RestController 테스트시 주로 사용되는 어노테이션은 @WebMvcTest와 @AutoConfigureMockMvc + @SpringBootTest 두 가지가 있다.
- @WebMvcTest는 스프링 MVC 컨트롤러를 테스트하기 위한 어노테이션이며, 애플리케이션의 다른 레이어를 불러오지 않고 컨트롤러와 관련된 Spring MVC 구성 요소만 로드한다.
- @AutoConfigureMockMvc + @SpringBootTest 조합은 주로 통합 테스트를 수행하기 위해 사용되는데, 실제 애플리케이션 컨텍스트를 로드하고 MockMvc를 자동으로 구성하여 HTTP 요청과 응답을 테스트할 수 있다.
- 컨트롤러를 테스트할 때, @WebMvcTest를 사용하여 테스트 범위를 컨트롤러에만 한정하고, 필요한 다른 레이어의 객체는 @MockBean을 사용하여 가짜 객체로 주입할 수 있다.
- 예외에 대한 테스트를 할 때, Mockito의 willThrow를 사용하여 예외를 발생시킬 수 있으며, 테스트에서 기대하는 예외를 확인할 수 있다. willThrow는 가짜 객체에 대해 예외를 설정할 때 사용한다.
글을 어떻게 끝내야할지 모르겠는데, 한 번더 상기하자
모르면 외우자. (반박시 님들 말이 다맞음ㅠ)
세상에서 가장 소름 돋는 것은 '아...이거 저번에 한 번 해봤던건데.. 뭐였지...' ......ㅋ
참고한 사이트
https://www.baeldung.com/java-mockito-when-vs-do
'Spring' 카테고리의 다른 글
스프링부트 + Redis를 활용한 최근 검색어/인기 검색어 구현 (0) | 2023.11.27 |
---|---|
스프링 빈(Bean)의 생명주기와 스코프 (0) | 2023.03.14 |
싱글톤 vs 스프링 싱글톤 (0) | 2023.03.13 |
스프링 어노테이션 (Spring Annotation) (1) | 2023.03.13 |
AOP의 특징과 개념 (feat. Filter, Interceptor) (0) | 2023.03.07 |