@WebMvcTest에 대한 올바른 사용법 및 시행착오
이번에 @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도 때로는 적극적으로 사용할만한 부분이라고 생각한다.