@WebMvcTest
, Spring MVC container
.. 스프링부트를 공부하다보면 꽤 자주 마주친다. 개념와 동작 방식을 매번 잊고 다시 공부하는 것 같아 이 참에 정리해보려 한다.
Spring MVC
vs Springboot
결론부터 말하자면, 별개의 것이 아니다. Springboot는 Spring 기반 어플리케이션(그게 Spring MVC 기반 어플리케이션일 수 있다.)을 편하게 개발할 수 있도록 한다. 이를테면 서버가 내장되어 있어 WAR 배포를 하지 않아도 되고, 의존성 관리를 starter dependency들로 편하게 할 수 있다. Spring MVC만으로 어플리케이션을 만들면 Tomcat과 같은 Servlet container에서 WAR 파일을 배포해야 한다.
Springboot에 MVC component들이 있다.
그래서 Spring MVC component들이 뭐지?
일단 Spring Web MVC는 위 기능들을 지원한다. spring-web module은 filter도 제공한다. 물론 Springboot를 사용하지 않는다면 자동 설정되지 않아 직접 등록해서 사용해야 할 것이다.
Spring Web MVC의 component들은 @WebMvcTest
에서 사용하는 bean들이라고 볼 수 있다. @WebMvcTest
는 전체 Spring application을 테스트(이는 @SpringBootTest
를 사용한다.)하는 것이 아니라 Web layer만을 테스트할 때 사용한다. @WebMvcTest
에서 사용하는 bean들은 공식 문서에 아래로 기재되어 있다.
i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans.
이해를 도울 사진이 있어 가져와 보았다. (사진 출처)
스프링 컨테이너는 IoC container(Application Context
)이다.
DispatcherServlet
Spring MVC는 다른 web framework와 마찬가지로 front controller 패턴을 사용한다. DispatcherServlet
이 바로 이 front controller이다.
DispatcherServlet
은 WebApplicationContext
를 갖는다.
WebApplicationContext
(및 서브 클래스들)는 여러 Servlet 인스턴스에서 공유해야 하는 Repository, Serivice와 같은 infrastructure bean이 포함되어 있다. 공식문서에 나와 있는 MVC context hierarchy 그림을 참고하자.
Thymeleaf
)굿노트에 잠들어 있던 김영한님 강의자료다. 친절하게 나와있지만 와닿지 않으니 코드로 직접 확인해보자.
Controller에서 어떻게 (동적) 페이지를 반환할까?
(정적 페이지는 Spring Static Resource를 참고하자.)
먼저 DispatcherServlet
가 요청을 받는다. 아래 코드는 DispatcherServlet
의 doDispatch
메소드의 일부이다.
HandlerAdapter로 ModelAndView(mv
)를 얻는다. 이 때 Mode은 페이지에 동적으로 넣을 데이터이고, View는 말 그대로 뷰에 대한 정보이다.
이 때 ModelAndView는 컨트롤러에서 반환해준 html의 경로 및 이름("/admin/index")을 가지고 있다. 이제 이 정보로 View 객체(나는 Thymeleaf를 사용하고 있으므로 ThymeleafView 객체)를 찾아 반환한다.
resolveViewName
메소드에서 View(ThymeleafView 객체)를 반환하고 있다.
이 때 사용하는 ContentNegotiatingViewResolver
에서 ThymeleafViewResolver
를 가지고 있는데, ThymeleafViewResolver는 default prefix 값이 classpath:/templates/이고 default suffix 값이 .html이다. 따라서 Controller에서 반환한 경로와 이름 만으로 html 파일을 찾을 수 있다.
프로젝트를 진행하면 테스트를 어떻게 할지 고민이 많아진다. Layered architecture 기준으로 business layer는 Mocking을 하여 슬라이싱 테스트를 하거나 JPA를 사용할 경우 여러 변수들 때문에 @Transactional
없이 @SpringbootTest
를 진행해왔다. 고민이 되었던 부분은 Presentation layer(Web layer)의 테스트였다.
API 명세 때문이라면 "E2E 테스트만으로 충분하지 않은가?"라고 생각했지만 이번에 MVC 컴포넌트들을 공부하니 확실히 테스트의 필요성을 알았다.
따라서 우테코 미션을 진행하면서 요구 사항에 기재되어 있지는 않았지만 아래처럼@WebMvcTest
, MockMvc
와 BDDMockito
로 슬라이싱 테스트를 하였다.
@WebMvcTest
public class ControllerTest {
@Autowired
protected MockMvc mockMvc;
@MockBean
protected ReservationService reservationService;
@MockBean
protected ReservationTimeService reservationTimeService;
@Autowired
protected ObjectMapper objectMapper;
@Test
@DisplayName("예약 목록 GET 요청 시 상태코드 200을 반환한다.")
void getReservations() throws Exception {
// given
ReservationTime expectedTime = new ReservationTime(1L, MIA_RESERVATION_TIME);
Reservation expectedReservation = MIA_RESERVATION(expectedTime);
BDDMockito.given(reservationService.getAll())
.willReturn(List.of(ReservationResponse.from(expectedReservation)));
// when & then
mockMvc.perform(get("/reservations").contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value(USER_MIA))
.andExpect(jsonPath("$[0].time.id").value(1L))
.andExpect(jsonPath("$[0].time.startAt").value(MIA_RESERVATION_TIME.toString()))
.andExpect(jsonPath("$[0].date").value(MIA_RESERVATION_DATE.toString()));
}
}
MockMvc
는 아래처럼 ApplicantionContext 없이 standalone 테스트도 가능하다. 아래에서는 setUp()
에서 따로 추가해주지 않았지만, 필터나 ControllerAdvice를 직접 구현했다면 추가해야 정상적으로 동작한다.
@ExtendWith(MockitoExtension.class)
public class StandaloneControllerTest {
@Mock
private ReservationService reservationService;
@InjectMocks
private ReservationController reservationController;
private MockMvc mockMvc;
@BeforeEach
void setup() {
this.mockMvc = MockMvcBuilders.standaloneSetup(reservationController).build();
}
@Test
@DisplayName("예약 목록 GET 요청 시 상태코드 200을 반환한다.")
void getReservations() throws Exception {
// given
ReservationTime expectedTime = new ReservationTime(1L, MIA_RESERVATION_TIME);
Reservation expectedReservation = MIA_RESERVATION(expectedTime);
BDDMockito.given(reservationService.getAll())
.willReturn(List.of(ReservationResponse.from(expectedReservation)));
// when & then
mockMvc.perform(get("/reservations").contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk());
}
}
만약 standalone test로 테스트한다면 직접 이 RestControllerAdvice를 등록해주어야 한다.
@BeforeEach
void setup() {
this.mockMvc = MockMvcBuilders.standaloneSetup(reservationController)
.setControllerAdvice(GlobalExceptionHandler.class)
.build();
}
등록해주지 않는다면 아래처럼 예외가 터진다. (테스트가 중단된다.)
반면 @WebMvcTest
로 테스트한다면 MVC component인 ControllerAdvice bean은 컨텍스트에 등록되어 있다. 따라서 예외 상태 코드와 메세지가 잘 응답된다.
standalone이 더 엄밀한 단위 테스트에 가깝지만, (나는) Web layer를 테스트하는 것에 초점을 둔다면 @WebMvcTest
가 더 유용하고 편한 테스트 설정이라고 판단했다.
[1] https://spring.io/guides/gs/testing-web
[2] https://docs.spring.io/spring-framework/reference/web/webmvc.html
[3] https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/autoconfigure/web/servlet/WebMvcTest.html
[4] https://thepracticaldeveloper.com/guide-spring-boot-controller-tests/