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
아래 예시 코드는 상품 등록 시, 필요한 정보 중 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
andExpect() 관련 참고 : https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework/server-defining-expectations.html
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가지 정보를 명시합니다.
- multipart 데이터 내에서 구분될 name
- file 명 : 실제 file 데이터에 해당하는 multipart 를 생성한다면 명시, 단순 text 데이터면 생략 가능
- 해당 데이터의 Content-Type
- 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 를 테스트 해보면, 아래처럼 성공하게 됩니다.
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://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST
'Test Code' 카테고리의 다른 글
[ATDD] RestAssured를 이용해 multipart/form-data 요청 테스트 시, 한글 깨지는 오류 해결하기 (0) | 2023.04.22 |
---|---|
@CsvSource annotation으로 여러 경우의 수 케이스 테스트하기 (0) | 2023.03.11 |
댓글