본문 바로가기
Test Code

[Spring MVC] @WebMvcTest 에서 Multipart 테스트 하기.

by 덩라 2023. 9. 22.

 

0. 개요

Controller 를 대상으로 단위테스트를 작성해보고자 했는데, 테스트 하고자 했던 API 가 상품 등록 API 였습니다.

상품 등록 시, 필요한 정보는 아래와 같았습니다.

1. 상품명, 상품가격, 재고수량, 상품의 상/하위 카테고리 정보, 상품 설명
2. 상품을 표시할 때 사용할 대표 이미지

 

1번의 내용만 필요했다면 단순했겠지만, 문제는 2. 상품을 표시할 때 사용할 대표 이미지 입니다.

통상적으로 File 같은 정보가 API 에 Request 에 포함되게 하기 위해선, Content-Type 을 application/json 방식이 아닌 multipart/form-data 방식을 사용해야 합니다.

따라서, 해당 API 를 테스트하기 위해선 multipart/form-data 방식의 요청을 테스트해야 했기 때문에 이를 테스트 하는 방법을 정리해보았습니다.

 

1. @WebMvcTest + MockMvc + Mocking(Mockito)

Spring MVC 를 테스트 하기 위해 Spring에서 제공해주는 @WebMvcTest 어노테이션을 사용할 수 있습니다.

@WebMvcTest 를 사용하면, Presentation Layer 에 해당하는 Bean 들만 테스트 컨텍스트에 등록해서 테스트에 사용할 수 있으므로, 모든 Layer 에 해당하는 Bean 들을 전부 등록하는 @SpringBootTest 보다 좀 더 빠른 테스트 결과를 받아볼 수 있습니다.

 

Presentation Layer 에 해당하는 Bean 들 만을 통해 테스트를 하는 경우, Application / Persistance Layer 에 속하는 Bean들은 Mockito 와 같은 방식으로 Mocking 해서 진행합니다.

실제 비지니스 로직은 Mocking 으로 처리해서 특정 결과를 return 한다는 가정을 명시하면, 실제로 비지니스 로직을 실행하지 않고도 return 받을 결과를 통해 Controller 테스트에 집중할 수 있습니다.

 

Controller 에 구현된 API 를 요청을 보내보기 위한 방법으로는 MockMvc 객체를 사용합니다.

MockMvc 객체는 실제 서버에 요청을 보내고 응답을 받아오는 대신, 가짜 요청과 응답을 만들어 Spring MVC 요청 과정을 수행함으로써, 실제로 클라이언트가 API 를 호출했을 때, Controller 가 실행되는 과정을 테스트 할 수 있습니다.

 

참고 : https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework.html

 

MockMvc :: Spring Framework

The Spring MVC Test framework, also known as MockMvc, provides support for testing Spring MVC applications. It performs full Spring MVC request handling but via mock request and response objects instead of a running server. MockMvc can be used on its own t

docs.spring.io

 

 

아래 예시 코드는 상품 등록 시, 필요한 정보 중 1. 상품명, 상품가격, 재고수량, 상품의 상/하위 카테고리 정보 를 등록하는 API 입니다.

@RequiredArgsConstructor
@RestController
public class ProductApiController {

    private final ProductService productService;

    @PostMapping("/products")
    public ResponseEntity<ProductResponse> createProduct(@RequestBody ProductRequest productRequest){
        ProductCreateDto productCreateDto = productRequest.toDto();
        ProductDto productDto = productService.saveProduct(productCreateDto);
        ProductResponse response = ProductResponse.of(productDto);

        return ResponseEntity.created(URI.create("/products/"+response.getId())).body(response);
    }
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProductRequest {
    private String name;
    private int price;
    private int quantity;
    private Long categoryId;
    private Long subCategoryId;
    private String detail;

    public ProductCreateDto toDto() {
        return ProductCreateDto.builder()
                .name(this.name)
                .price(this.price)
                .quantity(this.quantity)
                .categoryId(this.categoryId)
                .subCategoryId(this.subCategoryId)
                .detail(this.detail)
                .build();
    }
}

 

위 API 는 /products 라는 URI 를 POST 요청으로 호출할 수 있고, Request Body 에는 ProductRequest 객체의 field 값이 포함되게 됩니다. 다음 코드는 해당 API 를 MockMvc 를 활용하여 테스트하는 방법입니다.

import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(controllers = ProductApiController.class)
class ProductApiControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    ProductService productService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("상품을 등록한다.")
    void create_product() throws Exception {
        // given
        ProductRequest productRequest = new ProductRequest("상품 1", 12000, 2, 1L, 11L, "상품 설명입니다.");
        String content = objectMapper.writeValueAsString(productRequest);

        when(productService.saveProduct(any())).thenReturn(
                new ProductDto(
                        10L, "product-code-1", "상품 1",
                        12000, 2, "상품 설명입니다.",
                        new CategoryDto(1L, "상위 카테고리"),
                        new CategoryDto(11L, "하위 카테고리", 1L),
                        100L, "stored-file-name", "view-file-name")
        );

        // when & then
        mockMvc.perform(post("/products")
                        .contentType(MediaType.APPLICATION_JSON_VALUE)
                        .content(content))
                .andExpect(status().isCreated());
    }
}

 

Mockito 에서 제공하는 static method 인 Mockito.when() 을 활용하여, ProductService 객체의 input 과 output 을 mocking 할 수 있습니다. 이렇게 하면 실제로 ProductService.saveProduct() 를 실행하지 않고, thenReturn() 메서드를 통해 결과를 미리 정해놓음으로써, productService.saveProduct() 가 실행되었다고 가정하고 Controller 테스트에 집중할 수 있습니다.

 

mockMvc 객체는 @Autowired 를 통해 주입받고, mockMvc.perform() 메서드를 통해 테스트하고자 하는 API 의 URI 와 HttpMethod 를 지정하고, 필요한 Http Header 와 Body 를 명시할 수 있습니다.

그리고, andExpect() 메서드를 통해 원하는 응답이 왔는지 검증하게 됩니다.

 

perform() 관련 참고 : https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework/server-performing-requests.html

 

Performing Requests :: Spring Framework

You can also perform file upload requests that internally use MockMultipartHttpServletRequest so that there is no actual parsing of a multipart request. Rather, you have to set it up to be similar to the following example: If application code relies on Ser

docs.spring.io

 

andExpect() 관련 참고 : https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework/server-defining-expectations.html

 

Defining Expectations :: Spring Framework

You can define expectations by appending one or more andExpect(..) calls after performing a request, as the following example shows. As soon as one expectation fails, no other expectations will be asserted. You can define multiple expectations by appending

docs.spring.io

 

 

2. Multipart 객체를 MockMvc 로 검증하기

앞서 설명한 내용들은 Requet Body 가 단순한 application/json 형태만 존재할 때 유용합니다.

하지만, 첨부파일, 이미지 등의 단순 text 이외의 데이터를 API 요청에 담아서 보내야 한다면, application/json 방식 만으로는 구현할 수 없는데요. 

이럴 때 는 Http 의 Content-type 중 multipart/form-data 방식을 사용하여 request body 에 MIME 형태의 데이터를 담아서 요청을 보내야 합니다.

MIME 타입이란, 문서와 같은 텍스트 데이터와 파일 형태의 데이터의 집합으로 이루어진 데이터 형태를 의미합니다.
e-mail 전송에 사용되는 파일 형태로, Http 에선 multipart 방식의 데이터 통신에 주로 사용됩니다.
참고 : https://developer.mozilla.org/ko/docs/Web/HTTP/Basics_of_HTTP/MIME_types

 

Spring MVC 에서도 이와 같은 데이터 통신을 지원하기 위해 @RequestPart 라는 annotation 을 제공하고, 위에서 개발한 API 를 @RequestPart 형태로 수정하면 아래와 같이 수정될 수 있습니다.

@RequiredArgsConstructor
@RestController
public class ProductApiController {

    private final ProductService productService;

    @PostMapping("/products")
    public ResponseEntity<ProductResponse> createProduct(@RequestPart(name = "data") ProductRequest productRequest) {
        ProductCreateDto productCreateDto = productRequest.toDto();
        ProductDto productDto = productService.saveProduct(productCreateDto);
        ProductResponse response = ProductResponse.of(productDto);

        return ResponseEntity.created(URI.create("/products/"+response.getId())).body(response);
    }
}

기존에 @RequestBody annotation 에서 @RequestPart(name = "data") 라는 부분을 제외하곤 모두 동일하게 적용됩니다.

다만, Multipart 의 경우, 여러 데이터 타입을 수용하기 때문에 각 데이터 별로 name 을 지정하게 됩니다.

name="data" 는 multipart 데이터 내에 존재하는 data 라는 이름으로 구분된 데이터 라는 뜻 입니다.

 

위 처럼, 요청 데이터의 형태가 변경되었으니 테스트 코드에도 변경된 요청으로 요청을 보내도록 수정되어야 합니다.

수정된 테스트 코드는 아래와 같습니다.

import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(controllers = ProductApiController.class)
class ProductApiControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    ProductService productService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("상품을 등록한다.")
    void create_product() throws Exception {
        // given
        ProductRequest productRequest = new ProductRequest("상품 1", 12000, 2, 1L, 11L, "상품 설명입니다.");
        String content = objectMapper.writeValueAsString(productRequest);

        MockMultipartFile data = new MockMultipartFile(
                "data",
                "",
                MediaType.APPLICATION_JSON_VALUE,
                content.getBytes()
        );
        
        when(productService.saveProduct(any())).thenReturn(
                new ProductDto(
                        10L, "product-code-1", "상품 1",
                        12000, 2, "상품 설명입니다.",
                        new CategoryDto(1L, "상위 카테고리"),
                        new CategoryDto(11L, "하위 카테고리", 1L),
                        100L, "stored-file-name", "view-file-name")
        );

        // when & then
        mockMvc.perform(multipart("/products")
                            .file(data)
                )
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", notNullValue()));
    }
}

 

Spring MVC 에서 multipart 형태의 요청을 테스트 해보기 위해 MockMultipartFile 객체를 제공합니다.

해당 객체를 생성할 때, 아래 4가지 정보를 명시합니다.

  1. multipart 데이터 내에서 구분될 name
  2. file 명 : 실제 file 데이터에 해당하는 multipart 를 생성한다면 명시, 단순 text 데이터면 생략 가능
  3. 해당 데이터의 Content-Type
  4. byte 형태로된 실제 데이터

 

3. MockMultipartFile 객체로 파일업로드

단순 텍스트 데이터만을 request body 에 담는다면 위 과정 까지만 하면 되겠지만, 그렇다면 multipart 를 사용할 이유가 없습니다.

multipart 를 사용한다는 건, 파일 데이터를 다루기 위함일 것이니 이젠 제일 처음에 언급했던 상품 등록 시 필요한 데이터 중 

2. 상품을 표시할 때 사용할 대표 이미지 를 다뤄보려 합니다.

 

먼저, 이미지 파일을 업로드 할 수 있게 API 를 수정하면 아래와 같이 수정될 수 있습니다.

@RequiredArgsConstructor
@RestController
public class ProductApiController {

    private final ProductService productService;
    private final ThumbnailFileService thumbnailFileService;

    @PostMapping("/products")
    public ResponseEntity<ProductResponse> createProduct(@RequestPart(name = "data") ProductRequest productRequest,
                                                         @RequestPart(name = "file") MultipartFile showImgFile) throws IOException {

        ThumbnailInfo thumbnailInfo = thumbnailFileService.save(showImgFile);

        ProductCreateDto productCreateDto = productRequest.toDto(thumbnailInfo);
        ProductDto productDto = productService.saveProduct(productCreateDto);
        ProductResponse response = ProductResponse.of(productDto);

        return ResponseEntity.created(URI.create("/products/"+response.getId())).body(response);
    }
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProductRequest {

    private String name;
    private int price;
    private int quantity;
    private Long categoryId;
    private Long subCategoryId;
    private String detail;

    public ProductCreateDto toDto(ThumbnailInfo thumbnailInfo) {
        return ProductCreateDto.builder()
                .name(this.name)
                .price(this.price)
                .quantity(this.quantity)
                .categoryId(this.categoryId)
                .subCategoryId(this.subCategoryId)
                .detail(this.detail)
                .thumbnailInfo(thumbnailInfo)
                .build();
    }
}

 

Controller 에선 @RequestPart(name = "file") 이라는 파라미터가 추가되었고, 실제 파일을 저장하기 위한 ThumbnailFileService 객체를 추가했습니다.

그리고 Multipart/form-data 형태를 통해 요청된 파일 데이터는 Spring MVC 에서 제공하는 MultipartFile 객체로 받을 수 있습니다.

 

위와 같이 파일 데이터를 요청받는 API 로 수정됐고, 이를 테스트 하기 위한 테스트 코드 또한 수정되어야 합니다.

수정은 아래와 같이 될 수 있습니다.

import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(controllers = ProductApiController.class)
class ProductApiControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    ProductService productService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("상품을 등록한다.")
    void create_product() throws Exception {
        // given
        ProductRequest productRequest = new ProductRequest("상품 1", 12000, 2, 1L, 11L, "상품 설명입니다.");
        String content = objectMapper.writeValueAsString(productRequest);

        MockMultipartFile data = new MockMultipartFile(
                "data",
                "",
                MediaType.APPLICATION_JSON_VALUE,
                content.getBytes()
        );
        MockMultipartFile file = new MockMultipartFile(
                "file",
                "stored-file-name.png",
                MediaType.APPLICATION_OCTET_STREAM_VALUE,
                "test-image-file-content-with-string".getBytes()
        );
        
        when(productService.saveProduct(any())).thenReturn(
                new ProductDto(
                        10L, "product-code-1", "상품 1",
                        12000, 2, "상품 설명입니다.",
                        new CategoryDto(1L, "상위 카테고리"),
                        new CategoryDto(11L, "하위 카테고리", 1L),
                        100L, "stored-file-name", "view-file-name")
        );

        // when & then
        mockMvc.perform(multipart("/products")
                            .file(data)
                            .file(file)                            
                )
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", notNullValue()));
    }
}

 

앞에서 사용해본 MockMultipartFile 객체를 통해 MultipartFile의 Mock 객체를 생성했고, Content-Type 을 application/octet-stream 으로 명시했습니다. 

 

4. 테스트 결과 및 로그

위에서 개발된 API 를 테스트 해보면, 아래처럼 성공하게 됩니다.

상품 등록 API 단위테스트 결과

 

mockMvc 테스트를 통해 발생한 로그는 아래와 같이 확인할 수 있습니다.

로그는 andExpect() 메서드가 아닌 andDo() 메서드에 MockMvcResultHandlers.print() 메서드를 통해 확인할 수 있습니다.

 

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /products
       Parameters = {}
          Headers = [Content-Type:"multipart/form-data;charset=UTF-8"]
             Body = null
    Session Attrs = {}

Handler:
             Type = springboot.shoppingmall.product.presentation.ProductApiController
           Method = springboot.shoppingmall.product.presentation.ProductApiController#createProduct(ProductRequest, BindingResult, MultipartFile)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

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

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 201
    Error message = null
          Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Location:"/products/10", Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":10,"productCode":"product-code-1","name":"상품 1","price":12000,"quantity":2,"category":{"id":1,"name":"상위 카테고리"},"subCategory":{"id":11,"name":"하위 카테고리"},"partnerId":100,"partnersName":null,"thumbnail":"stored-file-name","detail":"상품 설명입니다.","qnas":null,"reviews":null}
    Forwarded URL = null
   Redirected URL = /products/10
          Cookies = []

 


참고 및 출처.

https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C/dashboard

https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types

https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST

https://spring.io/projects/spring-framework

댓글