멋쟁이 사자처럼 교육을 들으면서 TDD를 해야 더 깨끗하고 버그가 적은 코드로 이어진다는 것은 이해했지만 막상 '요구사항 정의서 수행형 개인 프로젝트' 에서는 혼자 백과 프론트의 구현을 잘 해보고자 하는 마음에 테스트 따위엔 신경쓸 겨를도 없이 구현에만 집중하게 됐었다. 나만 그런건 아니였더라...(하긴 테스트 코드를 잘 짜봤자 제한된 기간안에 구현하지 못하면 그게 무슨 소용이랴..라는 생각이 들기도 하는,,)
그치만 이제는 팀 프로젝트를 시작하는 만큼 각자 구현하는 기능에 테스트 코드를 잘 활용해보자는 이야기를 하며 각자 MockMvc라는 것에 대해 파악해보기로 했다.
MockMvc
- 스프링 프레임워크에서 제공하는 웹 애플리케이션 테스트용 라이브러리를 의미한다. 이를 사용하면 웹 애플리케이션의 다양한 컴포넌트를 테스트할 수 있다.
- MockMvc를 사용하면 HTTP 요청을 작성하고 컨트롤러의 응답을 검증할 수 있다. 이를 통해 통합 테스트를 실행하지 않고도 컨트롤러의 동작을 확인할 수 있다.
통합 테스트를 실행하지 않고도 이 말이 굉장한 장점인 것 같다. 인프라나 페이지 구현이 안됐을 때도 postman 같은 것을 사용할 필요 없이 컨트롤러의 동작을 확인할 수 있다는 것 !
- MockMvc를 이용하여 컨트롤러의 동작을 테스트하는 데 사용
- 컨트롤러의 엔드포인트를 호출하여 HTTP 클라이언트의 요청을 모방하고 적절한 응답을 확인하기 위해 테스트를 수행
- 이러한 테스트 과정을 통해 애플리케이션의 서비스 로직이나 API 엔드포인트가 의도한 대로 동작하는지 확인하고, 버그를 발견하고 수정하는 데 도움을 주는 것
perform(MockMvcRequestBuilders.get("/endpoint")) 와 같이 요청을 설정param("paramName", "paramValue") 와 같이 설정andExpect 메서드를 사용하여 응답의 상태코드, 헤더, 본문 등을 검증할 수 있다.andExpect(content().string("expectedValue")) 와 같이 검증을 추가한다.
이미지를 보니 Spring MVC에서 쓰이는 DispatcherServlet 대신 테스트를 위한 TestDispatcherServlet 이라는게 쓰인다는 느낌이다.
| 메서드 | 설명 |
|---|---|
| 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"인지 확인한다. |
| 메서드 | 설명 |
|---|---|
| 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 에서 보면 되겠지만 😅
| 메서드 | 설명 |
|---|---|
| getModelAndView() | 응답에 대한 모델과 뷰를 담고 있는 ModelAndView 객체를 반환한다. |
| getRequest() | 응답과 관련된 HttpServletRequest 객체를 반환한다. |
| getResponse() | 응답과 관련된 HttpServletResponse 객체를 반환한다. |
| getResponseHeaders() | 응답 헤더를 맵 형태로 반환한다. |
| getResponseStatus() | 응답 상태 코드를 반환한다. |
| getModel() | 응답으로부터 모델 객체를 반환한다. |
| getModelAndViewName() | ModelAndView 객체로부터 뷰의 이름을 반환한다. |
spring-boot-starter-test 라이브러리를 추가해야 한다.dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-core:5.8.0' // Mockito Core
}
// @ExtendWith: Mockito를 사용하여 테스트 클래스를 초기화하는 데 사용되는 어노테이션
@ExtendWith(MockitoExtension.class)
class MockControllerTest {
}
@ExtendWith(MockitoExtension.class)
class MockControllerTest {
// 컨트롤러 객체를 생성하고 인스턴스를 주입
@InjectMocks
private MockController mockController;
private MockMvc mockMvc; // 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();
}
}
@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"}
장점 : 실행 속도가 매우 빠르고, 다른 컴포넌트의 영향을 받지 않는 완전한 격리 테스트 가능
단점 : 의존성 주입, AOP 등 Spring의 기능을 사용할 수 없음, 실제 환경과 차이가 있을 수 있음
// 사용 예시 코드
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new MyController()).build();
장점 : 의존성 주입, AOP 등 Spring의 모든 기능 사용 가능, 실제 환경과 유사한 통합 테스트 가능
단점 : Standalone 방식보다 실행 속도가 느림, 테스트 설정이 더 복잡할 수 있음
@RunWith(SpringRunner.class)
@WebMvcTest(MyController.class)
public class MyControllerTest {
@Autowired
private MockMvc mockMvc;
// 테스트 코드
}
장점 : 가장 실제 환경과 유사한 테스트 가능, 전체 애플리케이션 호흡을 테스트할 수 있음
단점 : 세 방식 중 가장 실행 속도가 느림, 리소스 사용량이 많음, 테스트 설정이 가장 복잡함
@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 컨트롤러를 테스트할 수 있다는 점에서 개인적으로 굉장히 큰 장점으로 와닿았다.
아무래도 테스트 한 번 한다고 노트북이 뜨거워지는 것은 그리 달갑지만은 않기에... 꽤나 유용하게 사용할 수 있지 않을까 생각한다.
https://adjh54.tistory.com/347
https://katfun.tistory.com/195
https://velog.io/@jkijki12/Spring-MockMvc