2024. 4. 7. 02:10ㆍ테스트코드
1. 컨트롤러, 검증, ExceptionHandler 코드 확인
우선 작성한 컨트롤러 코드부터 확인해보자. 딱 1개의 API만이 있다. 가게를 찾기 위한 요청 정보를 URL의 파라미터로 받아 서비스 계층에서 사용할 수 있는 요청으로 바뀐 뒤, 가게를 찾는다. 이 때, 가게를 찾지 못하면 예외를 던지고 찾으면 상태코드 200과 함께 응답을 던진다.
@RestController
@RequiredArgsConstructor
public class ShopController {
private final ShopService shopService;
@GetMapping("/api/findShop")
public ResponseEntity<ShopSearchResponse> findShop(@Valid @ModelAttribute ShopSearchRequest request) {
ShopSearchServiceRequest serviceRequest = request.toServiceRequest();
Shop findShop = shopService.findRandomShop(serviceRequest);
if (findShop == null) {
throw new IllegalArgumentException("조회된 값이 없습니다. 설정 값을 변경해주세요.");
}
ShopSearchResponse response = new ShopSearchResponse(findShop);
return ResponseEntity.ok(response);
}
}
요청과 응답은 다음과 같이 생겼다. 요청 부분은 추가적으로 Builder와 서비스 요청으로 변환하는 부분의 코드는 길어서 뺐다. 여기서 요청 부분에서 검증하고자 하는 내용 중 대부분은 평범하지만 @AssertTrue 부분은 조금 특이하다. 애노테이션 방식의 검증 방식으로 해결할 수 없는 부분은 아래처럼 원하지 않는 상황을 false로 지정해주면 된다. 이 때, null 체크를 하는 이유는 값이 없는 경우, @NotNull 예외가 아니라 @AssertTrue가 먼저 시도되는 경우 nullPointException이 일어나서 오류가 발생했었기 때문이다.
@Data
public class ShopSearchRequest {
@NotNull(message = "학교 설정은 필수입니다.")
private SchoolType schoolType;
private List<ShopType> shopTypes;
@NotNull(message = "최소값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최소값은 0-20000까지 설정이 가능합니다.")
private Integer priceMin;
@NotNull(message = "최대값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최대값은 0-20000까지 설정이 가능합니다.")
private Integer priceMax;
@NotNull(message = "실시간 설정은 필수입니다.")
private Boolean realTime;
@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice() {
if (priceMin == null || priceMax == null)
return true;
return priceMin < priceMax;
}
}
@Getter
public class ShopSearchResponse {
String shopName;
int price;
public ShopSearchResponse(Shop shop) {
this.shopName = shop.getName();
this.price = shop.getPrice();
}
}
그럼 예외가 발생했을 때 @ExceptionHandler처리를 보자. 아래에서 BindExceptoin이 위의 검증이 실패했을 때 던져지는 예외, IllegalArgumentException이 가게를 찾지 못했을 때 던지는 예외이다.
@RestControllerAdvice
public class ApiControllerAdvice {
@ExceptionHandler(BindException.class)
public ResponseEntity<Object> bindException(BindException e) {
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(),
e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> IllegalArgumentException(Exception e) {
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(),
e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
}
이를 어떻게 검증해야 할까? 일단 확인해야 할 내용을 정리해보면 아래와 같을 것이다.
○ ExceptionHandler가 예외에 맞게 잘 작동을 하는가?
○ 검증 예외 상황에 따라 원하는 검증 로직이 동작했는가?
○ 예외가 아닌 경우 정상적으로 테스트 응답 결과가 나오는가?
그러면 테스트 코드 작성을 위해 무엇을 알아야 하는 지를 정리를 해보자.
○ 어떻게 요청을 보내나?
○ 어떻게 Json과 응답 코드를 확인하나?
○ 테스트 환경과 실제 환경이 다른데 어떻게 Shop 객체를 가지고 오는가?
이를 잘 기억하고 다음 단계로 넘어가보자.
2. 테스트 코드 작성
컨트롤러 계층은 다른 계층과 다르게 HTTP 프로토콜 스펙에 맞게 요청을 받고 응답을 보내는 부분이다. 이 때 마치 웹 브라우저에서 요청을 보내듯이 테스트에서 컨트롤러로 요청을 보내는 방법은 MockMvc를 사용하는 것이다. MockMvc는 스프링 애플리케이션 테스트 내에서 스프링 MVC처리를 돕는다.
우선, MockMvc를 사용하기 전에 다음처럼 테스트 코드 내에 설정을 하고 의존관계 주입을 해주자. MockBean이 뭔지는 이후에 설명하도록 하겠다.
@WebMvcTest(controllers = ShopController.class)
class ShopControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected ShopService shopService;
}
이제 MockMvc를 사용해보자. MockMvc를 체인 형태로 HTTP 요청을 만들고 응답을 확인할 수 있게 해준다.
요청을 만들기 위해선 perform() 함수 내부를 채워야 한다. perform() 함수 내부에서는 메서드 종류와 URL, Json과 Content-type 헤더를 정의할 수 있다.
또한 요청 결과와 응답 결과를 보고 싶다면 andDo(print())를 사용하면 된다. 마지막으로 응답 결과는 andExpect()를 사용해서 응답과 json 바디 내부를 확인하면 된다.
아래의 코드를 보면 바로 이해가 될 것이며 검증 부분은 아래와 같이 쿼리 파라미터 조건을 맞추고 모두 확인을 해주었다. 파라미터만 조금씩 바꾸면 확인이 가능하니 나머지는 생략하도록 하겠다.
@DisplayName("식당을 검색할 때에 학교 값은 필수이다.")
@Test
void searchShopWithoutSchoolType() throws Exception {
// given // when // then
mockMvc.perform(get("/api/findShop?priceMin=0&priceMax=20000&realTime=TRUE")
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.message").value("학교 설정은 필수입니다."));
}
예외 발생 부분은 저렇게 처리해주면 되는데 정상인 경우는 어떻게 확인을 해야할까 싶다. MockMvc를 사용하더라도 동작하는 컨트롤러는 실제 스프링 컨테이너 내부에 있는 컨트롤러이다. 그러니 빈에 등록된 코드가 작동이 되고 위의 검증 부분이 통과가 된 것이다. 그래서 컨트롤러 계층에서는 조금 고민해볼게 있다.
○ 서비스랑 리포지토리는 앞에서 테스트를 했는데 굳이 컨트롤러에서까지 테스트를 해야하나?
○ 내가 만들지 않는 스프링 외부 서버와 관련된 것들은?
○ 테스트인데 응답이 긴 메서드가 내부에 들어있는 경우는?
모든 것들을 경험해본 건 아니지만 1번의 경우는 어떻게 보면 앞의 테스트 코드가 또 반복되는 문제가 있고 DB를 타서 테스트 코드가 돌아가는 시간이 오래 걸리게 된다. 2번의 경우는 인증 서버인데 내가 작성하지 않은 외부는 문제를 발견해도 내가 해결할 수 없는 것이다. 3번의 경우도 파일 다운로드 같은 경우도 반복되면 시간과 메모리 낭비가 될 수 있다.
이런 상황을 해결해줄 수 있는 것이 MockBean이다. 위에 MockBean이 달린 ShopService는 내가 작성한 ShopService와는 다른 객체이다. 원랜 내부에 ShopRepository가 들어있어야 하지만 동작시켜보면 주입된 ShopRepository도 없다.
대신 해당 MockBean 내부의 메서드에 입력 값에 따른 응답값을 설정할 수 있다. 아래처럼 서비스에서 가게를 찾아오는 코드에 원하는 응답을 만들어서 원하는 요청과 매핑 시켜주면 사용이 가능하다.
이제 shopService의 findRandomShop 메서드 내부에는 설정한 요청과 동일한 값을 가진 객체가 들어오면 똑같은 값을 내보낸다. 중요한 건 동일한 값이다. 메서드에 파라미터가 있는 경우 설정한 값과 다른 값을 보내면 응답을 제대로 내뱉지 않는다.
// given
Shop shop = Shop.builder()
.name("학식")
.shopType(RESTAURANT)
.schoolType(NSC)
.price(8000)
.openTime(LocalTime.MIN)
.closeTime(LocalTime.MAX)
.build();
ShopSearchServiceRequest serviceRequest =
new ShopSearchServiceRequest(NSC, List.of(RESTAURANT), 5000, 10000, null);
given(shopService.findRandomShop(serviceRequest))
.willReturn(shop);
따라서 이에 맞게 MockMvc로 정확한 요청을 아래처럼 보내면 테스트가 통과하게 된다.
// when // then
mockMvc.perform(get("/api/findShop?schoolType=NSC&shopTypes=RESTAURANT&priceMin=5000&priceMax=10000&realTime=FALSE")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.shopName").value("학식"))
.andExpect(jsonPath("$.price").value(8000));
여기서 생각해볼 점은 다음과 같다. 위에서 제기한 문제들을 MockMvc가 잘 해결을 해줬다. DB를 안타서 시간도 줄여주고 서비스나 리포지토리 로직에 관해서 크게 신경 쓰지 않고 컨트롤러 계층의 테스트만 확실하게 하는데에 집중할 수 있었다.
그런데 이게 최선인지에 대해서는 고민해볼 필요가 있다. 생각난 예시로는 OSIV 범위가 Controller까지 물려있는 경우, 의도하지 않은 Update쿼리가 실제로 DB에는 날아가는데 MockBean 때문에 이를 확인하지 못하는 상황이 발생할 수 있을 것 같다. 이런 오류도 잡기 위해서는 MockBean을 사용하지 않는게 좋을 수도 있다고 생각한다.
개인적인 생각으로는 수정 관련 로직이 있는 경우에는 실제로 하고 조회, 외부 서버, 응답이 지나치게 긴데 확인할 필요가 없는 메서드는 MockBean으로 테스트할 계획이다.
[ 참고 자료 ]
https://spring.io/guides/gs/testing-web
https://www.baeldung.com/integration-testing-in-spring
https://www.baeldung.com/spring-mvc-test-exceptions
Practical Testing: 실용적인 테스트 가이드(박우빈 강의)
'테스트코드' 카테고리의 다른 글
JAVA 테스트 코드 작성 - 2. 테스트 코드에서 주의할 사항들(TIL) (0) | 2024.04.10 |
---|---|
JAVA 테스트 코드 작성 - 1. Mock 사용하기(TIL) (0) | 2024.04.09 |
스프링 테스트 작성 2. 서비스 계층 테스트 (0) | 2024.03.29 |
스프링 테스트 작성 1. 도메인과 리포지토리 설계 및 테스트 (0) | 2024.03.28 |