요약

@WebMvcTest Test 내에서만 사용하는 TestController를 등록하고, 사용하는 법? → @Import / @TestConfiguration / 따로 파일 설정 방식이 있고, 그 중 @TestConfiguration 이게 더 이해하기 좋아 보여서 이걸 택함.

배경

GlobalExceptionHandlerTest에서만 사용하는 TestController는 어떤 식으로 등록하면 좋을까?

참고:

@WebMvcTest(controllers = GlobalExceptionHandlerTest.TestController.class) 처럼 WebMvcTest에는 실제 테스트할 controller를 입력한다.

→ 지정한 컨트롤러 클래스만 로딩해서 슬라이스 테스트함. → 즉, 지정된 컨트롤러와 관련된 MVC 빈(@Controller, @ControllerAdvice, Jackson, MockMvc 등)만 등록됨.

@Import는 명시적으로 빈 등록하는 법.

package today.sesac.versebyverse.global.advice;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import java.util.Locale;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import today.sesac.versebyverse.global.advice.GlobalExceptionHandlerTest.TestController;
import today.sesac.versebyverse.global.config.MessageConfig;
import today.sesac.versebyverse.global.config.TestSecurityConfig;
import today.sesac.versebyverse.global.exception.LoginRequiredException;
import today.sesac.versebyverse.global.exception.PermissionRequiredException;

/**
 * 전역 예외 처리 테스트.
 */
@Import({TestController.class, GlobalExceptionHandler.class, TestSecurityConfig.class,
        MessageConfig.class})
//TODO: 테스트 커스텀 유저 추가 애노테이션 구현 필요
@WebMvcTest(controllers = GlobalExceptionHandlerTest.TestController.class)
class GlobalExceptionHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("[예외]-MethodArgumentNotValidException 처리 테스트")
    void handleMethodArgumentNotValidException() throws Exception {

        ObjectMapper objectMapper = new ObjectMapper();

        String invalidJson = objectMapper.writeValueAsString(new TestReq("invalidEmailFormat"));

        mockMvc.perform(post("/test/valid")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath(IS_SUCCESS).value(false))
                .andExpect(jsonPath("$.error[0].name").value("INVALID_REQUEST"))
                .andExpect(jsonPath("$.error[0].message").value("유효하지 않은 형식입니다. 요청 값을 다시 확인해 주세요."))
                .andExpect(jsonPath("$.error[0].param").value("email"))
                .andDo(MockMvcResultHandlers.print());
    }

    /**
     * 테스트용 컨트롤러.
     */
    @RestController
    static class TestController {

        @GetMapping("/test/error/runtime")
        public void testEndpoint() {

            throw new RuntimeException("Test Runtime Exception");
        }

        @GetMapping("/test/error/login-required")
        public void loginRequiredEndpoint() {

            throw new LoginRequiredException("member", "로그인이 필요합니다. - stack trace에 기록된 메시지");
        }

        @GetMapping("/test/no-permission")
        public void noPermissionEndpoint() {

            throw new PermissionRequiredException("member", "권한이 없습니다.");
        }

        @PostMapping("/test/valid")
        public void validTest(@Valid @RequestBody TestReq request) {
            // 요청이 유효하면 아무 일도 하지 않음
        }

    }

    /**
     * 테스트용 요청 DTO.
     *
     * @param email 이메일
     */
    record TestReq(
            @Email(message = "유효하지 않은 형식입니다. 요청 값을 다시 확인해 주세요.")
            String email
    ) {

    }
//
//    /**
//     * 테스트용 컨트롤러를 빈으로 등록하는 설정 클래스.
//     */
//    @TestConfiguration
//    static class TestConfig {
//
//        @Bean
//        public TestController testController() {
//
//            return new TestController();
//        }
//    }
}

과정

문제

@WebMvcTest의 controllers 속성은 스프링 빈으로 등록된 컨트롤러만 대상으로 스캔해 줌. 하지만 지금 TestController는 내부 static class라서, @SpringBootApplication 컴포넌트 스캔 경로에 포함되지 않음.

해결

  1. @Import에서 TestController 를 빈으로 등록해 가져오기.
  2. @TestConfiguration을 사용해 직접 Bean으로 등록하기.
  3. TestController 파일을 따로 만들고, @WebMvcTest(controllers = TestController.class) 이렇게 사용하기.

@Import가 깔끔할 것 같지만, 명시적으로 보이는 게 현 상황에서는 더 나을 것 같아서 @TestConfiguration을 사용해 직접 Bean으로 등록하는 방법을 택함.