[Spring] TDD에 MockMvc를 사용해보자

숙취엔 꿀물.·2024년 7월 28일

개요

멋쟁이 사자처럼 교육을 들으면서 TDD를 해야 더 깨끗하고 버그가 적은 코드로 이어진다는 것은 이해했지만 막상 '요구사항 정의서 수행형 개인 프로젝트' 에서는 혼자 백과 프론트의 구현을 잘 해보고자 하는 마음에 테스트 따위엔 신경쓸 겨를도 없이 구현에만 집중하게 됐었다. 나만 그런건 아니였더라...(하긴 테스트 코드를 잘 짜봤자 제한된 기간안에 구현하지 못하면 그게 무슨 소용이랴..라는 생각이 들기도 하는,,)

그치만 이제는 팀 프로젝트를 시작하는 만큼 각자 구현하는 기능에 테스트 코드를 잘 활용해보자는 이야기를 하며 각자 MockMvc라는 것에 대해 파악해보기로 했다.

1) MockMvc

MockMvc

  • 스프링 프레임워크에서 제공하는 웹 애플리케이션 테스트용 라이브러리를 의미한다. 이를 사용하면 웹 애플리케이션의 다양한 컴포넌트를 테스트할 수 있다.
  • MockMvc를 사용하면 HTTP 요청을 작성하고 컨트롤러의 응답을 검증할 수 있다. 이를 통해 통합 테스트를 실행하지 않고도 컨트롤러의 동작을 확인할 수 있다.

통합 테스트를 실행하지 않고도 이 말이 굉장한 장점인 것 같다. 인프라나 페이지 구현이 안됐을 때도 postman 같은 것을 사용할 필요 없이 컨트롤러의 동작을 확인할 수 있다는 것 !

1. MockMvc를 이용한 테스트 목적

  • MockMvc를 이용하여 컨트롤러의 동작을 테스트하는 데 사용
  • 컨트롤러의 엔드포인트를 호출하여 HTTP 클라이언트의 요청을 모방하고 적절한 응답을 확인하기 위해 테스트를 수행
  • 이러한 테스트 과정을 통해 애플리케이션의 서비스 로직이나 API 엔드포인트가 의도한 대로 동작하는지 확인하고, 버그를 발견하고 수정하는 데 도움을 주는 것

2. MockMvc를 이용한 Controller 내의 흐름

  1. TestCase -> MockMvc
  • TestCase 내에서 MockMvc 객체를 생성한다. 이 객체는 테스트할 컨트롤러와 상호작용을 하는 데 사용된다.

  1. MockMvc -> TestDispatcherServlet
  • MockMvc를 사용하여 원하는 엔드포인트에 요청을 보낸다. 또한 해당 요청에 필요한 파라미터, 헤더 또는 쿠키 등을 설정한다.
  • ex) GET 요청 시, perform(MockMvcRequestBuilders.get("/endpoint")) 와 같이 요청을 설정
  • 파라미터 설정은 param("paramName", "paramValue") 와 같이 설정

  1. TestDispatcherServlet -> Controller
  • 요청을 실행하고 응답을 받는다. andExpect 메서드를 사용하여 응답의 상태코드, 헤더, 본문 등을 검증할 수 있다.

  1. MockMvc -> TestCase
  • 필요한 검증을 추가한다.
  • ex) 응답 본문의 내용을 검증하고 싶다면 andExpect(content().string("expectedValue")) 와 같이 검증을 추가한다.

이미지를 보니 Spring MVC에서 쓰이는 DispatcherServlet 대신 테스트를 위한 TestDispatcherServlet 이라는게 쓰인다는 느낌이다.


2) MockMvc, ResultActions, MvcResult 주요 메서드

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/package-summary.html

1. MockMvc

메서드설명
standaloneSetup()특정 컨트롤러를 MockMvc에 설정하여 테스트할 수 있는 환경을 구성한다.
perform()MockMvc를 사용하여 HTTP 요청을 실행한다.
andExpect()컨트롤러의 응답을 검증한다.
andExpect(status().isOk())응답 상태 코드가 200인지 확인한다.
andExpect(content().string("expected"))응답 본문의 내용이 "expected"인지 확인한다.
andExpect
(jsonPath("$.property").value("expected"))
JSON 응답에서 특정 속성의 값이 "expected"인지 확인한다.
andExpect(view().name("expectedView"))응답에 대한 뷰의 이름이 "expectedView"인지 확인한다.
andExpect(model().attribute("attributeName", "expectedValue"))모델 속성의 값이 "expectedValue"인지 확인한다.
andExpect(redirectedUrl("expectedUrl"))리다이렉트된 URL이 "expectedUrl"인지 확인한다.

2. ResultActions

  • MockMvc를 사용하여 실행한 HTTP 요청에 대한 결과를 나타낸다. 이를 통해 컨트롤러의 응답을 검증하고 원하는 동작을 수행할 수 있다.
메서드설명
andReturn()해결된 MvcResult 객체를 반환한다.
andReturn(MvcResult)반환할 MvcResult를 설정한다.
andDo(ResultHandler)결과에 대해 추가 작업을 수행한다.
andDo(ResultMatcher)결과에 ResultMatcher를 추가한다.
andExpect(ResultMatcher)결과에 대한 기대치로 ResultMatcher를 추가한다.
andForward()요청을 다음 핸들러로 전달한다.
andForward(String)요청을 지정된 URL로 전달한다.
andExpect(MockMvcResultMatchers)MockMvcResultMatchers에서 ResultMatcher를 추가한다.
andExpect(MockMvcResultHandlers)MockMvcResultHandlers에서 ResultHandler를 추가한다.
andReverse()이전 전달을 뒤집는다.
andForwardDefault()요청을 기본 핸들러로 전달한다.
andForwardDefault(String)요청을 지정된 URL로 기본 핸들러로 전달한다.
andReturnDefault()기본 핸들러에 대한 해결된 MvcResult 객체를 반환한다.
andReturnDefault(MvcResult)기본 핸들러에 반환할 MvcResult를 설정한다.
andDoDefault()기본 핸들러에 대해 추가 작업을 수행한다.
andDoDefault(ResultHandler)지정된 핸들러를 사용하여 기본 핸들러에 대해 추가 작업을 수행한다.
andDoDefault(ResultMatcher)기본 핸들러에 ResultMatcher를 추가한다.
andExpectDefault(ResultMatcher)기본 핸들러에 대한 결과 기대치로 ResultMatcher를 추가한다.
andForwardNamed(String)지정된 URL로 명명된 핸들러로 요청을 전달한다.
andReturnNamed(String)명명된 핸들러에 대한 해결된 MvcResult 객체를 반환한다.
andDoNamed(String)명명된 핸들러에서 추가 작업을 수행한다.
andDoNamed(String, ResultHandler)지정된 핸들러를 사용하여 명명된 핸들러에서 추가 작업을 수행한다.
andDoNamed(String, ResultMatcher)명명된 핸들러에 ResultMatcher를 추가한다.
andExpectNamed(String, ResultMatcher)명명된 핸들러에 대한 결과 기대치로 ResultMatcher를 추가한다.
andReverseNamed(String)이전 명명된 핸들러로의 전달을 뒤집는다.
andReverseDefault()기본 핸들러로의 이전 전달을 뒤집는다.

🤔 여기서는 메서드를 몇개나 쓸지 모르겠지만 후에 다시 찾아와서 쓸 수도 있기 때문에 적어두었다.. 어처피 사용할 때 Intellij 에서 보면 되겠지만 😅

3. MvcResult

  • MockMvc에서 수행된 MVC 요청의 결과에 대한 상세한 정보를 제공한다.
  • 이 클래스는 응답 상태, 헤더, 내용 등과 같은 정보를 추출하기 위한 다양한 메서드를 포함하고 있다.
메서드설명
getModelAndView()응답에 대한 모델과 뷰를 담고 있는 ModelAndView 객체를 반환한다.
getRequest()응답과 관련된 HttpServletRequest 객체를 반환한다.
getResponse()응답과 관련된 HttpServletResponse 객체를 반환한다.
getResponseHeaders()응답 헤더를 맵 형태로 반환한다.
getResponseStatus()응답 상태 코드를 반환한다.
getModel()응답으로부터 모델 객체를 반환한다.
getModelAndViewName()ModelAndView 객체로부터 뷰의 이름을 반환한다.

3) 초기환경 구성

1. 의존성 주입

  • MockMvc를 사용하기 위해서는 spring-boot-starter-test 라이브러리를 추가해야 한다.
  • 또, Mockito 라이브러리 기능도 함께 사용하기에 의존성을 추가한다.
dependencies {
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.mockito:mockito-core:5.8.0' // Mockito Core
}

2. Controller 테스트를 위한 파일 생성

  • 본인의 테스트하고자 하는 컨트롤러 파일을 <Go To> - <Test> - 테스트할 메서드 선택 후 OK


4) MockMvc 단계 별 구성 과정 : Controller 테스트

1. Mock 초기화

  • MockMvc에서도 역시 Mock 객체를 초기화하는 데 사용된다.
  • 이를 수행하면 테스트에서 Mock 객체를 사용할 수 있고 테스트를 실행할 때 예상된 동작을 가진 Mock 객체를 사용할 수 있다.
// @ExtendWith: Mockito를 사용하여 테스트 클래스를 초기화하는 데 사용되는 어노테이션
@ExtendWith(MockitoExtension.class)
class MockControllerTest {

}

2. Mock Annotation & MockMvc 구성

@ExtendWith(MockitoExtension.class)
class MockControllerTest {

    // 컨트롤러 객체를 생성하고 인스턴스를 주입
    @InjectMocks
    private MockController mockController;

    private MockMvc mockMvc; // MockMvc를 선언
}

3. MockMvcBuilders.standaloneSetup(Class) 구성

  • 선언한 MockMvc에 MockMvcBuilders.standaloneSetup(Class) 를 통해 @Test가 실행되기 전에 독립적인 클래스(Controller)를 위해 선언하고 인스턴스를 생성하기 위해 구성한다.
@ExtendWith(MockitoExtension.class)
class MockControllerTest {

    @InjectMocks
    private MockController mockController;

    private MockMvc mockMvc;
    
    @BeforeEach
    void setUp() {
    	mockMvc = MockMvcBuilders.standaloneSetup(mockController).build();
    }
}

4. 테스트 예시

@RestController
public class postListController {

    @GetMapping("/code")
    public ResponseEntity<?> getPostsList(@RequestParam String cd) {
        return ResponseEntity.ok(Map.of("message", "success"));
    }
}

위와 같이 param 을 받고, key: message, value: "success" 를 Map으로 반환하는 엔드포인트가 "/code"인 컨트롤러의 메서드가 있다고 가정할 때, 테스트 코드는 아래와 같이 작성한다.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class) // Mockito를 사용하여 테스트 클래스를 초기화하는 데 사용됨
class postListControllerTest {

    @InjectMocks // 모의 객체를 생성하고 인스턴스를 주입하는데 사용됨
    private postListController postListController;

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        // 선언한 MockMvc에 아래 코드를 통해 @Test가 실행되기 전에
        // 독립적인 클래스(Controller)를 위해 선언하고 인스턴스를 생성하기 위해 구성한다.
        mockMvc = MockMvcBuilders.standaloneSetup(postListController).build();
    }

    @Test
    void getPostsList() throws Exception {

        // given
        String paramCd = "java";

        // when
        ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.get("/code")
                .param("cd", paramCd)
                .contentType(MediaType.APPLICATION_JSON));

        // then
        MvcResult mvcResult = resultActions
            .andExpect(status().isOk())
            .andDo(print())
            .andReturn();

        System.out.println("mvcResult :: " + mvcResult.getResponse().getContentAsString());
    }
}

이 때 andDo(print()) 를 통해 아래와 같은 결과를 얻을 수 있게 된다.

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /code
       Parameters = {cd=[java]}
          Headers = [Content-Type:"application/json"]
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = org.example.mockmvctest.controller.postListController
           Method = org.example.mockmvctest.controller.postListController#getPostsList(String)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"message":"success"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
mvcResult :: {"message":"success"}

5) (번외) MockMvc 테스트 방식

1. Standalone 테스트

  • 가장 가벼운 테스트 방식
  • 테스트 하려는 컨트롤러만 실행
  • Spring 컨텍스트를 로드하지 않음

장점 : 실행 속도가 매우 빠르고, 다른 컴포넌트의 영향을 받지 않는 완전한 격리 테스트 가능
단점 : 의존성 주입, AOP 등 Spring의 기능을 사용할 수 없음, 실제 환경과 차이가 있을 수 있음

// 사용 예시 코드
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MyController()).build();

2. Spring Context를 수행하여 테스트

  • Spring 컨텍스트를 로드하여 테스트
  • 실제 애플리케이션 구성과 유사한 환경에서 테스트

장점 : 의존성 주입, AOP 등 Spring의 모든 기능 사용 가능, 실제 환경과 유사한 통합 테스트 가능
단점 : Standalone 방식보다 실행 속도가 느림, 테스트 설정이 더 복잡할 수 있음

@RunWith(SpringRunner.class)
@WebMvcTest(MyController.class)
public class MyControllerTest {
    @Autowired
    private MockMvc mockMvc;
    // 테스트 코드
}

3. Web Server를 수행하여 테스트

  • 실제 웹 서버(ex: Tomcat)를 구동하여 테스트
  • 전체 애플리케이션 스택을 포함한 End-to-End 테스트

장점 : 가장 실제 환경과 유사한 테스트 가능, 전체 애플리케이션 호흡을 테스트할 수 있음
단점 : 세 방식 중 가장 실행 속도가 느림, 리소스 사용량이 많음, 테스트 설정이 가장 복잡함

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MyControllerIntegrationTest {
    @Autowired
    private TestRestTemplate restTemplate;
    // 테스트 코드
}

즉,, 위 세 가지 방법은 경우에 따라 아래와 같이 나눠지는거 같다.

단위 테스트 : Standalone
통합 테스트 : Spring Context 방식
End-to-End 테스트 : Web Server 방식

테스트의 목적, 속도, 범위 등을 고려하여 적절한 방식을 선택하여 테스트를 하도록 하자 !
실제 기업에서는 테스트가 어떻게 이루어지는지 모르겠으나.. 아마 대부분의 경우에서 standalone 방식을 많이 사용하지 않을까 싶다.



📑 마치며

테스트를 하는 방법에 정답은 없겠지만 이 MockMvc 라는 것은 일단 실제 서버를 구동하지 않고도 MVC 컨트롤러를 테스트할 수 있다는 점에서 개인적으로 굉장히 큰 장점으로 와닿았다.

아무래도 테스트 한 번 한다고 노트북이 뜨거워지는 것은 그리 달갑지만은 않기에... 꽤나 유용하게 사용할 수 있지 않을까 생각한다.


Reference

https://adjh54.tistory.com/347
https://katfun.tistory.com/195
https://velog.io/@jkijki12/Spring-MockMvc

profile
단단하게 오래가고자 하는 백엔드 개발자

0개의 댓글