WebMvcTest 진행 중 ComponentScan 에러

twonezero·2024년 10월 6일
0

문제

프로젝트 진행 중, 컨트롤러에 대한 단위테스트를 하기 위해 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 부분에 @WithMockUseruser() 를 통해 인증된 가짜 유저를 넣어 테스트를 수행하면 간단한 일이었다.

최종 테스트 코드

@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();
    }
}
profile
소소한 행복을 즐기는 백엔드 개발자입니다😉

0개의 댓글