[Springboot] Spring Web MVC와 Web layer 테스트

종미(미아)·2024년 4월 22일
6

🌱 Spring

목록 보기
3/9
post-thumbnail

들어가며

@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이다.
DispatcherServletWebApplicationContext를 갖는다.
WebApplicationContext(및 서브 클래스들)는 여러 Servlet 인스턴스에서 공유해야 하는 Repository, Serivice와 같은 infrastructure bean이 포함되어 있다. 공식문서에 나와 있는 MVC context hierarchy 그림을 참고하자.

동작방식 (feat. Thymeleaf)

굿노트에 잠들어 있던 김영한님 강의자료다. 친절하게 나와있지만 와닿지 않으니 코드로 직접 확인해보자.

Controller에서 어떻게 (동적) 페이지를 반환할까?
(정적 페이지는 Spring Static Resource를 참고하자.)

먼저 DispatcherServlet가 요청을 받는다. 아래 코드는 DispatcherServletdoDispatch 메소드의 일부이다.
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, MockMvcBDDMockito로 슬라이싱 테스트를 하였다.

@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());
    }
}

예를들어 아래처럼 RestControllerAdvice로 전역적으로 예외를 핸들링하고 있다고 하자.

만약 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/

profile
BE 개발자 지망생 🪐

0개의 댓글