🍃 Spring

@WebMvcTest에 대한 올바른 사용법 및 시행착오

loose 2023. 8. 22. 10:35
반응형

이번에 @WebMvcTest를 사용하면서 겪은 시행착오를 공유해 볼 예정이다.

@WebMvcTest란?

@WebMvcTest는 Spring Controller 영역에 대한 HTTP 요청만을 단위 테스트할 때 필요한 Bean들만 올려주는 기능을 가진 어노테이션이다.

실제 테스트는 MockMvc를 통해서 수행한다.

MockMvc는 HTTP 서버 요청을 직접하는 것이 아니라 컨트롤러에 대한 메소드만을 딱 잘라서 테스트하는 기능이라고 보면 된다.

속도 측면에서 @SpringBootTest를 사용하면서 MockMvc를 사용하면 모든 Spring Bean을 전부 로드해서 느리다는 단점이 있지만 @WebMvcTest는 웹 계층에서 필요한 빈들만 로드하는 기능을 가지고 있어서 불필요한 Bean들은 로드되지 않아 테스트 실행 속도가 빠르다.

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello, world!";
    }
}
@WebMvcTest(HelloController.class)
public class HelloControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testHelloEndpoint() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/hello"))
            .andExpect(MockMvcResultMatchers.status().isOk());
    }
}

@MockBean이란?

Controller 부분만 단위테스트를 하는 것이기 때문에 Controller에서 Service를 호출하는 부분을 가짜 행동으로 바꾸기 위해 보통 @MockBean을 이용해서 모킹 된 동작을 수행하도록 설정할 수 있다.

@MockBean
private ExternalService externalService; // ExternalService 빈을 모킹

@Test
public void testMyService() {
   	// externalService의 메서드가 모킹된 동작을 수행하도록 설정
	when(externalService.someMethod()).thenReturn("Mocked Response");
}

위처럼 @MockBean으로 설정된 Service를 When을 이용해 특정 동작으로 대체해서 수행할 수 있다.

 

@WebMvcTest와 관련된 Bean

@WebMvcTest를 사용하면 @SpringBootTest를 사용할 때 처럼 내부적으로 스프링 애플리케이션 콘텍스트를 설정하면서 필요한 Bean들을 로드하게 된다.

 

@WebMvcTest에서 로드하는 Bean

 

애플리케이션 컨텍스트를 설정하는 과정에서 @Controller, @RestController가 붙은 Spring Bean들을 필수적으로 로드한다.

그 외에도 Controller 계층과 관련이 있는 Filter, HandlerInterceptor, WebMvcConfigurer 등의 Spring Bean들을 로드하게 된다.

 

@WebMvcTest에서 로드하지 않는 Bean

 

Controller와 관련이 없는 @Component, @Configuration, @ConfigurationProperties 등은 스캔 대상에서 제외된다.

Bean을 못 불러올 시에 생기는 문제

@WebMvcTest를 좀 더 쉽게 설명하자면 마치
"너 
Controller 테스트할 때 이런 Bean들이 필요할 테니까 관련된 Bean들을 내가 로드해 줄게~"라는 식으로 작동하게 된다.

이 과정에서 특정 Bean이 제대로 로드가 되지 않는 부작용을 겪을 수 있는데 아래의 코드를 확인해보자.

@Component
@RequiredArgsConstructor
public class MyInterceptor implements HandlerInterceptor {
    private final TempComp tempComp;
}

만약에 위와 같은 HandlerInterceptor를 상속받는 MyInterceptor가 존재한다고 가정해 보자.

@Component
public class TempComp {
}

그리고 인터셉터 내부에서 사용하는 TempComp는 @Component로 선언되어 있다.

그럼 @WebMvcTest를 사용해서 단위테스트를 할 때 MyInterceptor는 Spring Bean으로 등록되지만 그 내부에서 사용하는 TempComp는 @Component이기 때문에 로드하지 못해서 아래와 같은 문제가 발생한다.

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration
혹은
required a bean of type~ 등 bean 을 찾지 못한다는 에러가 발생

이 문제를 어떻게 해결할 수 있는지 하나씩 살펴보자.

 

해결 방법 1. @Component 포함시키기

@WebMvcTest(controllers = HelloController.class)
@Import(TempComp.class)

위의 문제는 TempComp라는 Bean을 로드하지 못해서였다.

만약에 테스트에 인터셉터가 꼭 필요한 경우, 인터셉터에서 사용하는 TempComp Bean을 위와 같이 로드할 수 있다.

특정 Bean 클래스를 위와 같이 직접 적어줘도 되지만 @TestConfiguration으로 여러 개의 Bean을 한꺼번에 등록시킬 수도 있다.

@TestConfiguration
public class TestConfig {
    @Bean
    public TempComp tempComp(){
        return new TempComp();
    }
    // 그 외 다른 Bean들 추가
}
@WebMvcTest(controllers = HelloController.class)
@Import(TestConfig.class)

만약에 인터셉터가 필요하지 않은 경우라면 아래를 확인하자.

해결 방법 2. 인터셉터 제외하기

@WebMvcTest(controllers = HelloController.class,
     excludeFilters = { 
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = MyInterceptor.class) })

@WebMvcTest에는 속성으로 excludeFilters를 설정할 수 있다.

excludeFilters라는 이름에 filter가 들어가서 마치 Filter Class만 제외하는 것처럼 보이지만 위처럼 Interceptor도 exclude 할 수 있다.

위에서 설명한 @TestConfiguration으로도 제외하는 작업을 설정할 수도 있다.

@TestConfiguration
public class TestConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 인터셉터를 제외시키는 코드 추가
        // 예를 들어, registry.excludePathPatterns("/exclude/**");
    }
}

위와 같이 WebMvcConfigurer를 상속받아 특정 Bean을 포함하는 것뿐만 아니라 제외시키는 것도 가능하다.

@Component
public class MyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        chain.doFilter(request, response);
        // chain.doFilter를 적어주지 않으면 컨트롤러 mvc 테스트는 전부 200(ok)을 뱉으니 주의하자.
    }
}

필터까지 제외하고 싶다면 아래와 같이 2개 이상을 지정하면 된다.

excludeFilters = { 
	@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, 
    	classes = { MyInterceptor.class, MyFilter.class }) 
}

 

@AutoConfigureMockMvc?

필터를 제외하는 주제로 잠깐 비슷한 내용인 @AutoConfigureMockMvc에 대해 짧게 얘기해 보겠다.

@AutoConfigureMockMvc 어노테이션은 보통 @SpringBootTest와 함께 쓰이며 MockMvc를 초기화하는 어노테이션이다.

@SpringBootTest
@AutoConfigureMockMvc
class Test {
	@Autowired
    private MockMvc mockMvc;

위와 같이 @SpringBootTest에서 mockMvc에 대한 구성을 자동으로 해준다.

하지만 @WebMvcTest에서는 내부적으로 @AutoConfigureMockMvc 기능을 이미 갖고 있기 때문에 써줄 필요가 없는 기능이다.

@AutoConfigureMockMvc 어노테이션에서 Filter를 제외하는 명령이 있는데 아래와 같다.

@AutoConfigureMockMvc(addFilters = false)

대표적으로 addFilters = false는 인증, 인가를 Filter에서 처리하는 Spring Security Filter를 제외할 때 많이 쓰이는 속성이다.

그 외에도 @WebMvcTest에서는 excludeAutoConfiguration 설정을 넣을 수 있는데 @AutoConfiguration으로 설정된 Bean들을 예외 시키는 속성을 설정할 수 있다.
excludeAutoConfiguration = SecurityAutoConfiguration.class
보통 org.springframework.boot.autoconfigure 밑에 있는 것들 중 하나인 SecurityAutoConfiguration에 대해 exclude 하는 용도로 쓰인다.

해결 방법 3. @ConditionalOnProperty

@Bean
@ConditionalOnProperty(name = "myapp.feature.enabled", havingValue = "true")
public MyFeatureBean myFeatureBean() {
	return new MyFeatureBean();
}

@ConditionalOnProperty yaml 파일에 있는 프로퍼티 값에 따라 bean 선택적으로 로드할 있는 기능이다.

다만, 이 방식은 오로지 테스트 코드를 위해 설정파일을 건드리는 방식이기 때문에 우선적으로 고려할만한 상황은 아니다.

 

해결방법 4. Mockito 사용하기 

@ExtendWith(MockitoExtension.class)
class HelloControllerTest {

    @InjectMocks
    private HelloController helloController;
    
    @Mock
    private HelloService helloService;
    
    private MockMvc mockMvc;

    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(helloController)
                .build();
    }
}

Mockito는 Mock 객체를 활용해서 단위테스트하는데 유용한 라이브러리다.

Mockito는 위처럼 @WebMvcTest를 대체해서 사용하는 것도 가능하다.

당연한 얘기지만 위와 같이 사용하면 @WebMvcTest에서 자동으로 Filter, Interceptor들을 Bean으로 등록해 줬던 과정이 생략되기 때문에 필요하다면 아래와 같이 수동으로 등록해서 사용해야 한다.

@ExtendWith(MockitoExtension.class)
class HelloControllerTest {
    @InjectMocks
    private HelloController helloController;
    
    @InjectMocks
    private MyInterceptor myInterceptor; // MyInterceptor를 Mock으로 생성
    
    private MockMvc mockMvc;


    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(helloController)
                .addInterceptors(myInterceptor)
                .build();
    }

    @Test
    void test() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/hello"))
                .andExpect(MockMvcResultMatchers.status().isOk());
    }
}

 

@WebMvcTest의 Controller 지정에 대한 딜레마

@WebMvcTest(HelloController.class)
public class HelloControllerTest {

@WebMvcTest에 특정 컨트롤러를 적어주면 애플리케이션 콘텍스트는 해당 컨트롤러만 로드하게 된다.

그렇게 하면 다른 컨트롤러를 포함시키지 않으니 이 방식이 더 효율적으로 보일지 모른다.

근데 만약에 인텔리제이에서 Run All Tests와 같이 한꺼번에 모든 테스트를 돌릴 경우에는 속도가 느려진다.

 

애플리케이션 컨텍스트는 보통 여러 개의 테스트가 돌아갈 때 같은 애플리케이션 콘텍스트라면 캐싱해서 다음에 있을 테스트에 그대로 사용하게 된다.

만약에 @WebMvcTest에 특정 컨트롤러를 지정하지 않았을 경우에 한꺼번에 모든 컨트롤러를 애플리케이션 콘텍스트에 올린 뒤에 캐싱 기능을 이용하기 때문에 더 이상 애플리케이션 콘텍스트를 재생성하지 않고 재사용하게 돼서 속도가 빨라지는 것이다.

근데 특정 컨트롤러를 지정해 버리면 한꺼번에 모든 테스트를 돌릴 때 각각의 컨트롤러 테스트마다 애플리케이션 콘텍스트를 다시 생성하기 때문에 초기화 비용이 많이 발생해서 속도면에서는 더 느릴 수밖에 없다.

그럼 컨트롤러를 지정하지 않아야 할까? 그렇지 않다.

 

컨트롤러를 지정하지 않으면 발생하는 문제 중 가장 직관적으로 마주하는 부분은 Service다.

위에서 설명했듯이 Service Layer는 @WebMvcTest가 로드하는 대상이 아니기 때문에 @MockBean으로 서비스를 로드해서 사용해야 하는데 이때 모든 컨트롤러에서 사용하는 서비스들을 전부 로드해줘야 하는 문제점이 생긴다.

그렇기 때문에 @WebMvcTest에서는 필수적으로 특정 컨트롤러를 명시해줘야한다.

 

@MockBean의 부작용

 

위에서 설명했듯이 @Mockbean은 @SpringBootTest(통합테스트)나 @WebMvcTest(단위테스트)에서 가짜 객체를 만들 때 사용한다.

애플리케이션 콘텍스트 재로딩에 따른 속도 저하

@MockBean은 Spring 컨테이너 내에서 빈으로 등록되고 해당 빈이 실제로 호출되는 것처럼 동작하게 만들어준다.

그러므로 애플리케이션 콘텍스트에 빈이 더 추가되는 방식으로 동작하기 때문에 @MockBean은 새로운 애플리케이션 콘텍스트가 생성된다.

이는 테스트 속도가 당연히 더 느려질 수밖에 없다.

 반면에 Mockito의 @Mock은 완전히 격리된 환경에서 목 객체를 생성하고 동작을 정의하는 용도로 사용되기 때문에 애플리케이션 콘텍스트 자체가 존재하지 않고 테스트가 가능하다.

나쁜 디자인을 감출 수도 있는 @MockBean

여기서 핵심은 감춘다라는 표현이 아니라 감출 수도 있다는 표현이다.

향로님이 과거에 쓰신 @SpyBean @MockBean 의도적으로 사용하지 않기를 확인하면 @MockBean이 나쁜 디자인을 감춘다라는 걸 알 수 있다.

하지만 @MockBean을 사용해도 나쁜 디자인이 여지없이 드러나는 코드도 있는데 대표적으로 그게 WebClient다.

@Service
public class MyService {

    @Autowired
    private WebClient.Builder webClientBuilder;

    public String fetchData() {
        return webClientBuilder
                .baseUrl("https://jsonplaceholder.typicode.com")
                .build()
                .get()
                .uri("/posts")
                .retrieve()
                .bodyToMono(String.class)
                .block();
    }
}

위와 같이 Service Layer에서 WebClient를 직접 사용하는 경우에는 아래와 같이 테스트를 하게 된다.

@SpringBootTest
public class MyServiceTest {

    @Autowired
    private MyService myService;

    @MockBean
    private WebClient.Builder webClientBuilder;

    @Test
    public void testFetchData() {
        WebClient.RequestHeadersUriSpec<?> uriSpecMock = mock(WebClient.RequestHeadersUriSpec.class);
        WebClient.ResponseSpec responseSpecMock = mock(WebClient.ResponseSpec.class);

        given(webClientBuilder.baseUrl(anyString())).willReturn(webClientBuilder);
        given(webClientBuilder.build()).willReturn(uriSpecMock);
        given(uriSpecMock.get()).willReturn(uriSpecMock);
        given(uriSpecMock.uri(anyString())).willReturn(responseSpecMock);
        given(responseSpecMock.retrieve()).willReturn(responseSpecMock);
        given(responseSpecMock.bodyToMono(String.class)).willReturn(Mono.just("Test Data"));

        String result = myService.fetchData();
        assertEquals("Test Data", result);
    }
}

한눈에 봐도 복잡하지 않은가? WebClient처럼 빌드 패턴이 엮여있으면 관련된 모든 것을 stub을 해줘야 하는데 이 WebClient 코드를 Bean으로 분리시키면 위와 같은 상황을 탈출할 수 있다.

중요한 건 @MockBean을 쓰든 안 쓰든 해당 서비스에서 관련 없는 의존성이 무더기로 포함되어 있진 않은지 확인해 주는 것이 중요하다.

그래서?

여태까지 3개의 문제를 다뤄봤다.

  • @WebMvcTest에서 Bean들을 선택적으로 로드하는 문제
  • @WebMvcTest에서의 Controller 지정에 대한 딜레마
  • @MockBean 사용 시 속도 저하 문제(나쁜 코드 디자인은 예외로 한다)

위의 모든 문제는 Mockito를 사용하면 쉽게 해결될 수 있다.

하지만 위에서 설명했듯이 Mockito는 그만큼 복잡함이 요구된다. 

 

개인적으로 생각하는 결론만 얘기해 보자면 프로그래밍에서 단순함과 복잡함은 항상 Trade-Off 관계에 있다.

단순함을 추구하는 @WebMvcTest를 사용하면 정말 손쉽게 Controller 테스트를 할 수 있다는 장점이 있지만 속도가 느려지는 것은 필수다. 원래 느려질 거 감안하고 쓴다는 뜻이다.

대신에 Mockito는 복잡함을 추구하는 대신에 속도가 빠르다는 장점이 있다.

 

 

개인적으로 현재 하고 있는 프로젝트에선 Filter, Interceptor가 전부 필요하지 않은 상황이 대부분이라 현재로서는 @WebMvcTest를 사용하지 않고 Mockito로 대체해서 사용하는 것이 가장 깔끔하다고 생각하고 있다.

그렇다고 해서 항상 @WebMvcTest가 나쁜 것은 아니다.

이전에도 비슷한 글을 썼었는데 우리나라 천상계 개발자 두 분께서 이런 비슷한 말씀을 하셨다.

"단순함을 극도로 추구하도록 도와주는 기능이 일부 부작용이 있을지언정 그 기능을 사용함으로써 개발 속도와 비용 측면이 너무 저렴하다면 사용하는 것을 적극적으로 권장한다"라고 말했다.

 

간단한 프로젝트라면 @WebMvcTest를 쓰는게 더 좋다고 생각한다.

조금 복잡한 프로젝트라면 Mockito를 쓰는 것이 좋다고 생각한다. filter, interceptor 언제 하나하나 일일이 exclude 시키고 그걸 다 코드 중복시킬 거 생각하면 아찔하다고 해야하나..

아무튼 테스트 커버리지 100% 꽉꽉 맞추고 애플리케이션 올라갈 때마다 테스트코드 빌드하는 방식의 민감한 애플리케이션(금융)이 아니라면 @WebMvcTest도 때로는 적극적으로 사용할만한 부분이라고 생각한다.

728x90