프로젝트 진행 중, 컨트롤러에 대한 단위테스트를 하기 위해 Mockito 와 WebMvcTest 를 이용해 테스트를 작성하고 있었다.
현재 프로젝트는 여러 멀티모듈로 이어져 있고, application 에서 다른 모듈의 component 도 scan 하고 있어서 SpringBootTest 를 통해 모든 context 를 load 하는 것은 비효율적이라고 판단했다. 그래서 WebMvcTest 로 진행~!
@DisplayName("[Reservaition] - TimeSlot")
@AutoConfigureMockMvc
@WebMvcTest(TimeSlotController.class)
class TimeSlotControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private TimeSlotService timeSlotService;
@Autowired
private ObjectMapper objectMapper;
@DisplayName("[POST] 예약 타임 슬롯 생성 - 정상 호출")
@Test
@WithMockUser(username = "user@example.com", roles = {"PROVIDER"})
void 예약타임슬롯_생성_성공() throws Exception {
// 테스트용 데이터 준비
LocalDate localDate = LocalDate.parse("2024-10-02");
CreateTimeSlotRequest request = new CreateTimeSlotRequest(1L, localDate, 20);
TimeSlotDto timeSlotDto = createTimeSlotDto(localDate);
given(timeSlotService.createTimeSlot(any(CreateTimeSlotRequestDto.class),any())).willReturn(timeSlotDto);
// POST 요청 수행 및 결과 검증
mvc.perform(post("/api/time-slots")
.contentType("application/json")
.content(objectMapper.writeValueAsString(request))
.with(csrf())
.with(user("user@example.com").roles("PROVIDER"))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value(TIMESLOT_CREATE_SUCCESS.getMessage()))
.andExpect(jsonPath("$.data.id").value(timeSlotDto.id().toString()))
.andExpect(jsonPath("$.data.serviceProviderId").value(1L))
.andExpect(jsonPath("$.data.availableDate").value("2024-10-02"))
.andExpect(jsonPath("$.data.availableTime").value(20))
.andExpect(jsonPath("$.data.isReserved").value(false))
.andDo(print());
then(timeSlotService).should().createTimeSlot(any(CreateTimeSlotRequestDto.class), any());
}
//Other codes...
}
해당 테스트는 예약가능시간정보 CRUD 에 대한 단위테스트를 목적으로 작성되었다. 하지만 WebMvcTest + Application의 ComponentScan의 영향으로 TimeSlotController와 전혀 상관 없는, 다른 Controller들을 빈으로 등록하다가 에러가 발생한다.
Application 에는 아래와 같이 다른 멀티모듈( common, security...) 등의 필요한 component 들을 scan 하도록 설정해놓았었다.
@SpringBootApplication
@EnableFeignClients
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
@ComponentScan(basePackages = {"com.tk.gg.common","com.tk.gg.security"})
public class ReservationApplication { //Reservation 모듈에는 reservation, review, timeSlot 서비스가 존재한다.
public static void main(String[] args) {
SpringApplication.run(ReservationApplication.class, args);
}
}
common 과 security 등은 다른 멀티모듈에 존재하므로 어떻게 수정해야 하는지 고민이었다.
그래서, 관련 Spring document 를 찾아보았더니 WebMvcTest 의 bean 스캔에 대한 글을 발견했다.
Auto-configured Spring MVC Tests
공식문서에 나와 있는 어노테이션으로 만들어진 Component 외에는 따로 @Import
를 통해 추가적으로 scan 하도록 명시해야한다고 이해했다.
그러면 WebMvcTest는 Configuration 파일을 스캔하지 않기 때문에, ComponentScan 을 Application 에 설정하지 않고 Configuration 파일을 따로 작성해 설정하면 될 것 같았다.
package com.tk.gg.reservation.infrastructure.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = {"com.tk.gg.common","com.tk.gg.security"})
public class ComponentScanConfig {
}
지금 보니까, infrastructure 디렉토리에 해당 config 를 생성한 게 조금 어색한 것 같긴 하지만... 일단 놔두고 테스트를 실행해보자.
별 것 아닌 성공테스트들 이지만...초록색이 뜨면 항상 기분은 좋다...
사실, 위의 문제가 발생하기 전에 또 다른 문제가 있었다.
현재 프로젝트는 MSA 로 이루어져 있고, Security 모듈이 존재했다. 그리고 요청 프로세스는 아래와 같다.
사용자가
gateway
를 통해auth-service
에 회원가입 및 로그인 요청 ->security
모듈에서 인증 정보 저장 및 다음 요청 시 인증 로직 수행, request 별 인가 -> 유저 정보를 methodArgumentResolver 에 담기
프로세스에서, 요청 인자 resolver 에 담기 위해 어노테이션과 HandlerMethodArgumentResolver
를 구현하였고, 아래와 같이 컨트롤러에서 유저 정보를 얻을 수 있다.
@PostMapping
public GlobalResponse<TimeSlotResponse> create(
@RequestBody @Valid CreateTimeSlotRequest request,
@AuthUser AuthUserInfo userInfo //유저 정보
) {
return ApiUtils.success(
TIMESLOT_CREATE_SUCCESS.getMessage(),
TimeSlotResponse.from(timeSlotService.createTimeSlot(request.toDto(), userInfo))
);
}
문제는 MockMvc 기반의 단위 컨트롤러 테스트를 진행할 때, AuthUserInfo
의 구현체를 mocking 해서 argumentResolver 에 등록하려고 했기에 발생했다.
여기서
AuthUserInfo
자체는 인터페이스이고, impl 의 생성자는 모두 PRIVATE 으로 설정 및builder
를 통해 생성되게 작성되었기 때문에 에러가 발생한다.
@BeforeEach
public void setup() {
// SecurityMockMvc 설정
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(SecurityMockMvcConfigurers.springSecurity()) // Spring Security 설정 적용
.build();
given(authUserArgumentResolver.supportsParameter(any())).willReturn(true);
given(authUserArgumentResolver.resolveArgument(any(), any(), any(), any()))
.willReturn(AuthUserInfoImpl.builder()
.id(1L)
.email("user@example.com")
.username("TestUser")
.userRole(UserRole.PROVIDER)
.token("sample-token")
.build());
}
AuthUserInfo
라는 것은 사실 Security 모듈에 존재하고, WebMvcTest 에서는 관심을 가지지 않아도 되는 부분이었다. 그런데, 계속 저 부분에 집착하느라 시간을 많이 날린 것 같다...
어떻게 해도 나의 기존 지식과 검색으로는 해결이 되지 않아 그냥 setup 부분을 지우고, 컨트롤러의 요청이 잘 수행되고 데이터가 원하는 형식으로 반환되는지만 확인하였다. ( 이게 맞는 것 같음 )
이제, Test 부분에
@WithMockUser
와user()
를 통해 인증된 가짜 유저를 넣어 테스트를 수행하면 간단한 일이었다.
@DisplayName("[Reservaition] - TimeSlot")
@AutoConfigureMockMvc
@WebMvcTest(TimeSlotController.class)
class TimeSlotControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private TimeSlotService timeSlotService;
@Autowired
private ObjectMapper objectMapper;
@DisplayName("[POST] 예약 타임 슬롯 생성 - 정상 호출")
@Test
@WithMockUser(username = "user@example.com", roles = {"PROVIDER"})
void 예약타임슬롯_생성_성공() throws Exception {
// 테스트용 데이터 준비
LocalDate localDate = LocalDate.parse("2024-10-02");
CreateTimeSlotRequest request = new CreateTimeSlotRequest(1L, localDate, 20);
TimeSlotDto timeSlotDto = createTimeSlotDto(localDate);
given(timeSlotService.createTimeSlot(any(CreateTimeSlotRequestDto.class),any())).willReturn(timeSlotDto);
// POST 요청 수행 및 결과 검증
mvc.perform(post("/api/time-slots")
.contentType("application/json")
.content(objectMapper.writeValueAsString(request))
.with(csrf())
.with(user("user@example.com").roles("PROVIDER"))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value(TIMESLOT_CREATE_SUCCESS.getMessage()))
.andExpect(jsonPath("$.data.id").value(timeSlotDto.id().toString()))
.andExpect(jsonPath("$.data.serviceProviderId").value(1L))
.andExpect(jsonPath("$.data.availableDate").value("2024-10-02"))
.andExpect(jsonPath("$.data.availableTime").value(20))
.andExpect(jsonPath("$.data.isReserved").value(false))
.andDo(print());
then(timeSlotService).should().createTimeSlot(any(CreateTimeSlotRequestDto.class), any());
}
@DisplayName("[GET] 예약타임슬롯 페이징, 정렬 조회 - 정상 호출")
@Test
@WithMockUser(username = "user@example.com", roles = {"PROVIDER"})
void 예약타임슬롯_전체조회_페이징및정렬_성공() throws Exception {
Sort sort = Sort.by(Sort.Order.desc("availableDate"));
Pageable pageable = PageRequest.of(0, 5, sort);
// 저장 메서드 모킹
given(timeSlotService.getAllTimeSlot(eq(null),eq(null), eq(pageable)))
.willReturn(new PageImpl<>(List.of(), pageable, 0));
mvc.perform(get("/api/time-slots")
.queryParam("page", "0")
.queryParam("size", "5")
.queryParam("sort", "availableDate,desc")
.contentType(MediaType.APPLICATION_JSON)
.with(csrf())
.with(user("user@example.com").roles("PROVIDER"))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.page.size").value(5))
.andExpect(jsonPath("$.message").value(TIMESLOT_RETRIEVE_SUCCESS.getMessage()))
.andDo(print());
then(timeSlotService).should().getAllTimeSlot(eq(null),eq(null), eq(pageable));
}
@DisplayName("[GET] 예약타임슬롯 단건 조회 - 정상 호출")
@Test
@WithMockUser(username = "user@example.com", roles = {"PROVIDER"})
void 예약타임슬롯_단건조회_성공() throws Exception {
// 저장 메서드 모킹
LocalDate localDate = LocalDate.parse("2024-10-02");
TimeSlotDto timeSlotDto = createTimeSlotDto(localDate);
given(timeSlotService.getTimeSlotDetails(timeSlotDto.id())).willReturn(timeSlotDto);
mvc.perform(get("/api/time-slots/{timeSlotId}", timeSlotDto.id())
.contentType(MediaType.APPLICATION_JSON)
.with(csrf())
.with(user("user@example.com").roles("PROVIDER"))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value(TIMESLOT_RETRIEVE_SUCCESS.getMessage()))
.andDo(print());
then(timeSlotService).should().getTimeSlotDetails(timeSlotDto.id());
}
@DisplayName("[POST] 예약 타임 슬롯 수정 - 정상 호출")
@Test
@WithMockUser(username = "user@example.com", roles = {"PROVIDER"})
void 예약타임슬롯_수정_성공() throws Exception {
// 테스트용 데이터 준비
LocalDate localDate = LocalDate.parse("2024-10-02");
UpdateTimeSlotRequest request = new UpdateTimeSlotRequest(1L,localDate,10,false);
willDoNothing().given(timeSlotService)
.updateTimeSlot(any(UUID.class),any(UpdateTimeSlotRequestDto.class),any());
// POST 요청 수행 및 결과 검증
mvc.perform(put("/api/time-slots/{timeSlotId}", UUID.randomUUID())
.contentType("application/json")
.content(objectMapper.writeValueAsString(request))
.with(csrf())
.with(user("user@example.com").roles("PROVIDER"))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value(TIMESLOT_UPDATE_SUCCESS.getMessage()))
.andDo(print());
then(timeSlotService).should().updateTimeSlot(any(UUID.class),any(UpdateTimeSlotRequestDto.class), any());
}
private TimeSlotDto createTimeSlotDto(LocalDate localDate){
return TimeSlotDto.builder()
.id(UUID.randomUUID())
.availableDate(localDate)
.serviceProviderId(1L)
.isReserved(false)
.availableTime(20).build();
}
}