MockMvc
MockMvc
Spring MVC Test Framework라고도 부르며 실행중인 서버 대신 모의 Request와 Reponse 객체들의 처리를 수행합니다.
MockMvc는 자체적으로 요청을 수행하고 응답을 확인하는데 사용할 수 있으며, 서버로 연결되어 있는 WebTestClient API를 통해 요청을 처리할 수도 있습니다. 하지만 실제 컨테이너에 의존하지 않고 Mock 객체로 테스트 하기 때문에 실제 클라이언트 및 서버에서 실행하는 통합 테스트와 비교할 때 몇가지 차이가 있습니다.
-
MockHttpServletRequest를 사용하기 때문에 기본 컨텍스트 경로가 없음.
-
jsessionId 쿠키, forwarding, 비동기 디스패치가 없음.
-
JSP 랜더링이 없기 때문에 JSP 페이지를 확인할 순 있지만 HTML은 렌더링 되지 않음.
단 Tymeleaf 및 Freemarker는 HTML, JSON, XML등 렌더링이 가능함. (all other rendering technologies that do not rely on forwarding)
-
요청 매핑, 데이터 바인딩, 메시지 변환, Type변환, 유효성 검사를 하지 않으며 @InitBinder, @ModelAttribute, @ExceptionHandler 메서드를 지원하지 않습니다.
그럼에도 MockMvc로 단위 테스트하는 이유는 서버가 실제 HTTP 클라이언트를 통해 테스트할 때처럼 불투명 상자가 아니므로 기대치를 기록하기가 더 쉽기 때문입니다. 즉 테스트는 서버측이므로, 어떤 핸들러가 사용되었는지, 만일 예외가 핸들러 예외 해결기로 처리되었는지, 모델의 내용이 무엇인지, 어떤 바인딩 오류가 있었는지, 그리고 다른 세부 사항들을 확인할 수 있다는 것입니다.
MockMvc를 직접 사용하여 요청을 수행하려면 아래 패키지들을 static import하여 사용합니다.
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
설정방법
MockMvc는 두 가지 설정 방법 중 하나로 설정할 수 있습니다.
첫번째 방법은 테스트할 Controller를 직접 지정하여 사용하는 standaloneSetup 방식.
class DemoControllerTest {
MockMvc mockMvc;
@BeforeEach
void setUp() {
this.mockMvc = MockBuilders.standaloneSetup(new DemoContoller()).build();
}
//...
}
standaloneSetup 방식은 하나의 Controller를 대상으로 테스트하며, Controller에 모의 종석성을 수동으로 주입할 수 있으며 스프링 구성을 로드하지 않습니다. 어떤 컨트롤러를 시험하고 있는지, 작동을 위해 특정한 스프링 MVC 구성이 필요한지 등을 더 쉽게 확인할 수 있도록 합니다. 특정 동작을 확인하거나 문제를 디버깅하기 위한 임시 테스트를 작성하는 매우 편리한 방법입니다.
두번째 방법은 모든 Controller를 대상으로 테스트할 수 있는 webAppContextSetup 방식.
@SpringJUnitWebConfig
class DemoContollerTest {
MockMvc mockMvc;
@BeforeEach
void setUp(WebApplicationContext wac) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
//...
}
webAppContextSetup은 실제 Spring MVC 구성을 로드하여 통합 테스트를 보다 완벽하게 수행합니다. TestContext 프레임워크는 로드된 Spring 구성을 캐시하기 때문에 더 많은 테스트를 빠르게 실행할 수 있습니다. 또한 웹 계층 테스트에 집중하기 위해 Spring 구성을 통해 컨트롤러에 모의 서비스를 주입할 수 있습니다. 모든 테스트를 webAppContextSetup으로 작성하여 항상 실제 Spring MVC 구성에 따라 테스트할 수 있습니다.
MockMvc 실습전에 알고 넘어갈 것
**BDD **
BDD(Behavior-Driven Development) 행위 주도 개발을 말하며 테스트 대상의 상태의 변화를 테스트하는 것이다.
//given (테스트 대상이 어떠한 상태에서 출발하며)
//when (어떤 상태 변화를 가했을 때)
//then (기대하는 상태로 완료되어야 한다)
JsonPath
Json 객체에 접근할 수 있는 Expression을 제공하고 Matcher와 혼합하여 기대치를 작성하는데 필요하다.
https://github.com/json-path/JsonPath
XML관련된 XPath도 있음.
hamcrest
hamcrest는 JUnit에 사용되는 Matcher 라이브러리를 제공
org.hamcrest.core: Object나 Value 값들에 대한 Matcher
org.hamcrest.beans: Java Bean과 값 비교에 사용되는 Matcher
org.hamcrest.collection : 배열과 컬렉션 관련 Matcher
org.hamcrest.number
org.hamcrest.object
org.hamcrest.test 등이 있음.
실습 예제
DemoConfig.java
@Configuration
public class DemoConfig implements WebMvcConfigurer {
@Bean(name = "mvcTaskExecutor")
TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor te = new ThreadPoolTaskExecutor();
te.setCorePoolSize(10);
te.setMaxPoolSize(100);
te.setQueueCapacity(50);
te.initialize();
return te;
}
}
DemoController.java
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
@RestController
public class DemoController {
@GetMapping("/simple")
public String testMvcResult(String name) {
return name;
}
@GetMapping("/testAsync")
@Async("mvcTaskExecutor")
public CompletableFuture<String> testAsync(String name)
{
return CompletableFuture.supplyAsync(() ->
{
randomDelay();
return name;
});
}
private void randomDelay()
{
try
{
Thread.sleep(ThreadLocalRandom.current().nextInt(5000));
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
@PostMapping("/requestMultipart")
public ResponseEntity<String> requestMultipart(@RequestParam("file") MultipartFile file) {
return file.isEmpty() ?
new ResponseEntity<>(HttpStatus.NOT_FOUND) : new ResponseEntity<>(HttpStatus.OK);
}
@PostMapping("/requestPostDto")
public List<DemoDto> requestPostDto(@RequestBody DemoDto user) {
List<DemoDto> resultList = new ArrayList<>();
String[] userNames = new String[]{"파", "마늘", "양파"};
Arrays.stream(userNames)
.forEach(value -> resultList.add(DemoDto.builder()
.userName(value)
.userId(user.getUserId())
.userPassword(user.getUserPassword())
.build()));
return resultList;
}
@PostMapping("/requestPostString")
public String postRequestString(String name, String country) {
return name;
}
@GetMapping("/requestGetDto")
public DemoDto requestGetDto(DemoDto user) {
return user;
}
}
DemoControllerTest.java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
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.setup.MockMvcBuilders;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.context.WebApplicationContext;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.springframework.test.web.servlet.ResultMatcher.matchAll;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringJUnitWebConfig
@Slf4j
class DemoControllerTest {
MockMvc mockMvc;
ObjectMapper objectMapper;
@BeforeEach
void setUp(WebApplicationContext wac) {
this.objectMapper = new ObjectMapper();
this.mockMvc = MockMvcBuilders.standaloneSetup(new DemoController())
.alwaysDo(log())
.build();
}
@Test
@DisplayName("MvcResult 테스트")
void testMvcResult() throws Exception {
//given
String testUserName = "감자칩";
//when
MvcResult result = this.mockMvc.perform(get("/simple")
.param("name", testUserName)
.contentType("text/plain;charset=UTF-8")
.accept("text/plain;charset=UTF-8"))
.andReturn();
//then
Assertions.assertEquals(result.getResponse().getContentAsString(StandardCharsets.UTF_8), testUserName);
}
@Test
@DisplayName("비동기 테스트")
void testAsync() throws Exception {
//given
String testUserName = "브래드";
//when
MvcResult mvcResult = this.mockMvc.perform(get("/testAsync")
.param("name", testUserName)
.contentType("text/plain;charset=UTF-8")
.accept("text/plain;charset=UTF-8"))
.andExpect(status().isOk())
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(testUserName))
.andReturn();
//then
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().string(testUserName));
}
@Test
@DisplayName("DTO를 Json String으로 변환하여 Post 요청")
void requestPostDto() throws Exception {
//given
DemoDto dto = DemoDto.builder()
.userId("test")
.userPassword("1234")
.userName("study")
.age(30)
.build();
//when
ResultActions resultActions = this.mockMvc.perform(post("/requestPostDto")
.content(objectMapper.writeValueAsString(dto))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON));
/*
[
{"userId":"test","userPassword":"1234","userName":"파","age":0},
{"userId":"test","userPassword":"1234","userName":"마늘","age":0},
{"userId":"test","userPassword":"1234","userName":"양파","age":0}]
*/
//then
resultActions
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
// .andExpect(header().string(HttpHeaders.LOCATION, "/requestPostDto"))
.andExpect(matchAll(
jsonPath("$.[*].userName").isNotEmpty(),
jsonPath("$.[*].userPassword").isNotEmpty()
))
.andExpect(
jsonPath(".[*].userName",
Matchers.everyItem(
Matchers.anyOf( /* anyOf, allOf, both, either ... */
Matchers.is(notNullValue()),
Matchers.is("양파"),
Matchers.hasItem("파"),
Matchers.containsString("마늘"),
Matchers.not("감자")
/* matchesRegex, startsWith, startsWithIgnoringCase, endsWith ... */
)
)
)
);
}
@Test
@DisplayName("String으로 Post 요청")
void requestPostString() throws Exception {
//when
ResultActions resultActions = this.mockMvc.perform(post("/requestPostString")
.param("name", "donghyeok")
.param("country", "대한민국")
.contentType("text/plain;charset=UTF-8")
.accept("text/plain;charset=UTF-8")
);
//then
resultActions.andExpect(status().isOk());
}
@Test
@DisplayName("DTO를 MultiValueMap 타입으로 변환하여 GET 요청")
void requestGetDto() throws Exception {
//given
DemoDto dto = DemoDto.builder()
.userId("test")
.userPassword("1234")
.userName("study")
.age(30)
.build();
//when
ResultActions resultActions = this.mockMvc.perform(get("/requestGetDto")
.params(convertDtoToMultiValueMap(objectMapper, dto))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
//then
resultActions.andExpect(status().isOk());
}
@Test
@DisplayName("Multipart 요청")
void requestMultipart() throws Exception {
//given
MockMultipartFile file = new MockMultipartFile(
"file",
"hello.txt",
MediaType.TEXT_PLAIN_VALUE,
"Hello, World!".getBytes()
);
//when
ResultActions resultActions = this.mockMvc.perform(multipart("/requestMultipart").file(file));
//then
resultActions.andExpect(status().isOk());
}
//아래 함수 출처: https://jojoldu.tistory.com/478?category=635883
MultiValueMap<String, String> convertDtoToMultiValueMap(ObjectMapper objectMapper, Object dto) {
try {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.setAll(objectMapper.convertValue(dto, new TypeReference<Map<String, String>>() {}));
return params;
} catch (Exception e) {
log.error("Url Parameter 변환중 오류가 발생했습니다. requestDto={}", dto, e);
throw new IllegalStateException("Url Parameter 변환중 오류가 발생했습니다.");
}
}
댓글남기기