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 변환중 오류가 발생했습니다.");
        }
    }

댓글남기기