[사용 스택]
- Spring Boot 3.x
- Java 17
- Spring data JPA
- H2
- Junit 5
- mockito
이번 글에서는 Controller의 단위테스트를 작성한 방법을 소개하겠습니다.
들어가기에 앞서서,
저는 Rest Controller를 사용하여 REST API를 만들었습니다.
Controller
컨트롤러의 역할은 HTTP 요청에 대한 비즈니스 로직 호출과 적절한 응답을 해주는 것입니다.
저는 RestControllerAdvice를 사용해서 전역에서 발생된 예외에 맞게 응답 데이터를 생성해 주도록 했습니다.
따라서, 제가 작성한 컨트롤러에서는 성공적인 응답만을 반환해줍니다.
또한, 통일된 응답 데이터를 만들기 위해서 ResponseDTO <T>를 만들어 두었습니다.
@AllArgsConstructor
@Getter
public class ResponseDTO<T> {
private String message;
private T data;
public static <T> ResponseDTO<T> success(T data) {
return new ResponseDTO<>("SUCCESS", data);
}
public static <T> ResponseDTO<T> success(){
return new ResponseDTO<>("SUCCESS", null);
}
public static <T> ResponseDTO<T> error(String message){
return new ResponseDTO<>(message, null);
}
}
테스트 방법
이번에는 HTTP 요청부터 응답까지를 확인해야 하므로 @WebMvcTest(~~ Controller.class)를 사용합니다.
MockMvc 객체를 @Autowired를 통해 자동으로 주입받아 사용할 것입니다.
그리고 주입받아야 할 객체들(서비스와 레포지토리)에 @MockBean 어노테이션을 붙여 Mock 객체로 받습니다.
Given에서 Mockito.when() 메서드를 사용해서 서비스의 반환 값을 Mockito.mock()으로 정의해 줍니다.
또한, HTTP 요청에 사용될 URL, Request Params, Request Body 등을 정의합니다.
When에서는 MockMvc의 perform() 메서드를 통해 컨트롤러에 요청을 보냅니다.
Then에서는 .andExpect() 메서드를 통해 응답된 HTTP 상태값과 데이터를 검증하고
서비스의 호출을 verify() 메서드를 통해 검증합니다.
아래는 제가 작성한 테스트 코드입니다.
// ...
@WebMvcTest(DeviceRestController.class)
class DeviceRestControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
DeviceServiceImpl deviceService;
@MockBean
CategoryRepository categoryRepository;
private StringBuilder sb;
@BeforeEach
void setUp() {
sb = new StringBuilder("/api/v2/devices");
}
@Test
public void findDeviceList() throws Exception {
//given
when(deviceService.findDeviceList(any(FindDeviceListReqDTO.class)))
.thenReturn(mock(FindDeviceListResDTO.class));
String requestUrl = sb.toString();
MultiValueMap<String, String> requestParams = new LinkedMultiValueMap<>();
requestParams.add("page", "0");
requestParams.add("size", "10");
//when
ResultActions action = mockMvc
.perform(get(requestUrl)
.params(requestParams)
);
//then
verify(deviceService, times(1))
.findDeviceList(any(FindDeviceListReqDTO.class));
action.andExpect(status().isOk())
.andExpect(jsonPath("data").isNotEmpty())
.andDo(print());
}
@Test
public void findDeviceListByCategory() throws Exception {
//given
when(deviceService.findDeviceListByCategory(any(FindDeviceListByCategoryReqDTO.class)))
.thenReturn(mock(FindDeviceListResDTO.class));
String requestUrl = sb.append("/by/{categoryId}").toString();
Long categoryId = 1L;
MultiValueMap<String, String> requestParams = new LinkedMultiValueMap<>();
requestParams.add("page", "0");
requestParams.add("size", "10");
//when
ResultActions action = mockMvc
.perform(get(requestUrl, categoryId)
.params(requestParams)
);
//then
verify(deviceService, times(1))
.findDeviceListByCategory(any(FindDeviceListByCategoryReqDTO.class));
action.andExpect(status().isOk())
.andExpect(jsonPath("data.deviceList").isNotEmpty())
.andDo(print());
}
@Test
public void findCategory() throws Exception {
//given
when(categoryRepository.findAll())
.thenReturn(mock(ArrayList.class));
String requestUrl = sb.append("/categories").toString();
//when
ResultActions action = mockMvc
.perform(get(requestUrl));
//then
verify(categoryRepository, times(1))
.findAll();
action.andExpect(status().isOk())
.andExpect(jsonPath("data").isArray())
.andDo(print());
}
@Test
public void findMobileInfo() throws Exception {
//given
when(deviceService.findMobileInfo(any(FindDeviceInfoReqDTO.class)))
.thenReturn(mock(FindDeviceInfoResDTO.class));
String requestUrl = sb.append("/mobile/{detailId}").toString();
Long detailId = 1L;
//when
ResultActions action = mockMvc
.perform(get(requestUrl, detailId));
//then
verify(deviceService, times(1))
.findMobileInfo(any(FindDeviceInfoReqDTO.class));
action.andExpect(status().isOk())
.andExpect(jsonPath("data").isNotEmpty())
.andDo(print());
}
@Test
public void findLaptopInfo() throws Exception {
//given
when(deviceService.findLaptopInfo(any(FindDeviceInfoReqDTO.class)))
.thenReturn(mock(FindDeviceInfoResDTO.class));
String requestUrl = sb.append("/laptop/{detailId}").toString();
Long detailId = 1L;
//when
ResultActions action = mockMvc
.perform(get(requestUrl, detailId));
//then
verify(deviceService, times(1))
.findLaptopInfo(any(FindDeviceInfoReqDTO.class));
action.andExpect(status().isOk())
.andExpect(jsonPath("data").isNotEmpty())
.andDo(print());
}
}
고민한 내용
우선 컨트롤러를 테스트할 때 어떤 기능을 테스트해야 하는지를 고민했습니다.
HTTP Request Params/Body 데이터의 검증을 해야 하는지,
비즈니스 로직에서 발생된 예외를 처리의 검증을 해야 하는지....
제 결론은 요청을 정상적으로 받았다는 가정으로
필요한 비즈니스 로직 메서드를 호출하는지를 검증하고,
이에 따라서 적절한 응답 데이터가 반환되는지를 검증해야 한다고 생각합니다.
요청 데이터의 검증은 DTO 클래스 혹은 파라미터에 @Valid 어노테이션을 통해
원하지 않는 값으로 요청이 들어왔을 때 적절한 예외가 발생되는지를
따로 검사해야 한다고 생각합니다.
또한, 발생된 예외를 ControllerAdvice를 통해 전역으로 관리하기 때문에
예외 핸들링을 단독으로 테스팅해야 한다고 생각합니다.
이렇게 테스트의 목적에 맞게 구분되도록 관리해 주는 것이
테스트를 작성하기도, 수정하기도 용이할 것이라는 개인적인 생각입니다...ㅎㅎ
결론
지금까지 제가 Repository부터 Service, Controller의 단위 테스트를 진행했던 방법에 대해서 정리해 보았습니다.
테스트 코드를 작성하며 기존의 코드들에 몇몇의 수정 혹은 추가가 필요한 문제점들도 확인할 수 있었습니다.
테스트 코드는 항상 귀찮은 존재였는데,
이번 단위 테스트를 통해 기존의 코드들이 더 퀄리티 높은 코드로 변하고 있는 것을 느끼게 되어서
필수적으로 해야겠다는 생각이 들었습니다.
아쉬웠던 부분들과 변경이 필요한 부분들을 빠르게 수정하고
통합 테스트를 통해 더 확실한 검증을 하고자 합니다.
'WEB > Spring' 카테고리의 다른 글
QueryDsl에서 DTO로 조회할 때 @QueryProjection 사용하기 (0) | 2024.12.18 |
---|---|
SpringDataJpa와 QueryDsl 함께 사용하기 (feat. @DataJpaTest) (1) | 2024.12.16 |
단위 테스트 - Service (feat. 테스트 더블) (0) | 2024.08.28 |
단위 테스트 - Repository (feat. 테스트 픽스쳐) (0) | 2024.08.27 |
Service의 메서드들이 중복해서 사용하는 로직을 테스트하기 (0) | 2024.08.20 |