Closed binchoo closed 2 years ago
package org.binchoo.study.spring.multipart.profileservice.controller;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.binchoo.study.spring.multipart.profileservice.config.WebConfig;
import org.junit.jupiter.api.*;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockPart;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import javax.servlet.http.Part;
import static org.assertj.core.api.Java6Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringJUnitWebConfig(classes = {WebConfig.class})
public class FileUploadControllerTests {
MockMvc mvc;
FileUploadController controller = new FileUploadController();
MockPart mockPart = new MockPart("file", "filename.png", "file".getBytes());
MockPart mockJsonPart = new MockPart("user", "{\"name\": \"jaebin-joo\", \"age\":\"11\"}".getBytes());
MockMultipartFile mockFile = new MockMultipartFile("file", "filename.png", "image/png", "file".getBytes());
@BeforeEach
public void setup() {
this.mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Order(1)
@DisplayName("params + Part -> params + Part")
@Test
void bindParamsAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(2)
@DisplayName("params + File -> params + MultipartFile")
@Test
void bindParamsAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/param/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(3)
@DisplayName("params + Part -> VO + Part")
@Test
void bindVOAndPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/part")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(4)
@DisplayName("params + File -> VO + MultipartFile")
@Test
void bindVOAndFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vo/multipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(5)
@DisplayName("params + Part -> VO Having Part (Fails)")
@Test
void bindVOHavingPart() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vopart")
.part(mockPart)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().is4xxClientError()) // StandardMultipartFile 타입을 Part로 변환할 수 없기 때문에
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(6)
@DisplayName("params + File -> VO Having MultipartFile")
@Test
void bindVOHavingFile() throws Exception {
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/vomultipartfile")
.file(mockFile)
.param("name", "jaebin-joo")
.param("age", "11"))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(1);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(7)
@DisplayName("json-part + Part -> VO + Part")
@Test
void bindJsonAndPart() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/part")
.part(mockPart)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Order(8)
@DisplayName("json-part + File -> VO + MultipartFile")
@Test
void bindJsonAndFile() throws Exception {
mockJsonPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MultipartHttpServletRequest request = (MultipartHttpServletRequest)
mvc.perform(multipart("/json-part/multipartfile")
.file(mockFile)
.part(mockJsonPart))
.andExpect(status().isOk())
.andReturn().getRequest();
assertThat(request.getParts().size()).isEqualTo(2);
assertThat(request.getMultiFileMap().size()).isEqualTo(0);
}
@Controller
private class FileUploadController {
@PostMapping("/param/part")
public ResponseEntity bindParamsAndPart(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/param/multipartfile")
public ResponseEntity bindParamsAndFile(@RequestParam("name") String name,
@RequestParam("age") int age, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/part")
public ResponseEntity bindVOAndPart(UserVO userVo, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/vo/multipartfile")
public ResponseEntity bindVOAndFile(UserVO userVo, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
@PostMapping("/vopart")
public ResponseEntity bindVOHavingPart(UserHavingPartVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/vomultipartfile")
public ResponseEntity bindVOHavingFile(UserHavingFileVO userVo){
if (userVo.getFile() == null)
return ResponseEntity.noContent().build();
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/part")
public ResponseEntity bindJsonAndPart(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") Part file){
return ResponseEntity.ok().build();
}
@PostMapping("/json-part/multipartfile")
public ResponseEntity bindJsonAndFile(@RequestPart(value = "user") UserVO user, @RequestPart(value = "file") MultipartFile file){
return ResponseEntity.ok().build();
}
}
@Setter
@Getter
@NoArgsConstructor // jackson-databind objectMapper 사용을 위해
@AllArgsConstructor
private static class UserVO {
private String name;
private int age;
}
@Setter
@Getter
private static class UserHavingPartVO extends UserVO {
private Part file;
public UserHavingPartVO(String name, int age, Part file) {
super(name, age);
this.file = file;
}
}
@Setter
@Getter
private static class UserHavingFileVO extends UserVO {
private MultipartFile file;
public UserHavingFileVO(String name, int age, MultipartFile file) {
super(name, age);
this.file = file;
}
}
}
MultipartFile 말고 Part 써보기
서블릿 3.0 부터
javax.servlet.http.Part
를 통해 파일 업로드를 처리할 수 있게 되었습니다.하지만 스프링 5.1.8을 사용하는 중에 이 친구가 제대로 동작하는 걸 보지 못 했는데요.
[연관 이슈](https://github.com/spring-projects/spring-framework/issues/26501#issuecomment-772597270)
이슈가 수정돼 스프링 5.3.2로 버전 업하여
Part
를 사용해 보니 묘하게 눈 여겨 볼 사항들이 있었습니다.multiPartFileMap
가 사용되지 않고parts
가 사용됨. 목 요청 생성에file()
를 쓰나part()
를 쓰나 관계 없었음.file()
로 파일을 담아도Part
객체가 받을 수 있음. 반대로part()
로 파일을 담아도MultipartFile
객체가 받아줄 수 있음.Part
이면 주입을 해 줄수 없었음.이런 미묘함으로
Part
사용이 망설여질 때, 테스트를 통과하는 유스케이스를 안다면확신을 갖고
Part
를 써볼 수 있을 것입니다.따라서 몇 가지 유스케이스를 준비하고 이를 커버하는 테스트를 진행해 보았습니다.
환경 구성
StandardServletMultipartResolver
빈 등록이 멀티파트 리졸버는
Part
사양을 지원하는 구현체입니다. 디스패처 서블릿에 이 빈을 등록합시다.Object Mapper 등록
오브젝트 매퍼가 컨텍스트에 추가되어 있지 않다면 직접 등록합니다. JSON을 VO(Value Object)로 변경할 때 이용하겠습니다.
멀티파트 설정 추가
MultipartConfigElement
객체에 멀티파트 관련 설정을 생성하고registration
에 추가합니다.8가지 업로드 유스케이스
테스트 케이스 소개
javax.servelet.http.Part
이나MultipartFile
으로 업로드 파일 획득하는 것을 살펴봅니다. HTTP 요청의 형태와 핸들러 메서드의 시그니처에 의해 8가지로 확인해 보려합니다.TC 이름에서 화살표 왼편과 오른편이 나누어져 있으니 설명하겠습니다.
왼쪽은 Mock 멀티파트 요청의 모양을 의미합니다.
param()
으로 객체 멤버를 넣어준다는 의미part()
로 객체의 JSON 문자열 표현을 넣어준다는 의미file()
로 업로드 파일을 넣겠다는 의미part()
로 업로드 파일을 넣겠다는 의미오른쪽은 요청을 처리하는 핸들러의 인자의 모습입니다.
MultipartFile
로 주입 받습니다.Part
로 주입 받습니다.수신 객체 정의
HTTP 요청에 담긴 자료를 뽑아 바인딩 해 두는 객체입니다.
UserVO
는 이름과 나이를 저장하며,UserHaving...
는 업로드된 파일도 저장하고자 합니다.이 때
Part
멤버는 스프링이 주입하지 못 한다는 점을UserHavingPartVO
로 확인할 것입니다.테스트 자료 준비
HTTP 요청에 실을 자료들을 준비합니다.
MockMVC
를 사용하여multipart()
빌더로 리퀘스트를 작성할 것이며, 테스트 자료을 담아 핸들러에 보냅니다.이 때,
MockMultipartHttpServletRequest
타입의 요청이 생성됩니다.컨트롤러 작성
TC에 대응하는 8개의 컨트롤러 메서드를 작성합니다.
테스트 목적은 핸들러 인자에 값이 잘 바인딩 되는 지를 보는 것이니 메서드 내용은 별 것 없습니다.
바인딩이 성공하면 테스트는 성공이므로 메서드는 OK를 반환합니다.
테스트 작성
앞서 설명한 8가지 유스케이스를 확인합시다.
이들은 통과하므로 스프링 5.3.2에서 통과하는 유스케이스 세트가 생겼습니다. 그러므로
Part
를 사용한 파일 업로드 구현에 참고할 수 있습니다.여기서 5번째 TC와 메서드는 주의해야 합니다.
다른 핸들러들은 인자 값들을 제대로 전달 받길 원하지만
컨트롤러 5번째
bindVOHavingPart(UserHavingPartVO userVo)
메서드는 실패하길 기대합니다.요청 작성시
part()
를 썼고, 객체 멤버의 타입이Part
이지만, 스프링은 업로드 된 파일을 전달해 주지 못 합니다.왜 그런가요?
멀티파트 요청
StandardMultipartHttpServletRequest
이 핸들러의 인자 객체의 멤버에게 값을 주입할 땐 그 타입이StandardMultipartHttpServletRequest.StandardMultipartFile
입니다.이 클래스는
MultipartFile
을 상속합니다. 그리고Part
를 래핑하고 있습니다.하필
StandardMultipartHttpServletRequest.StandardMultipartFile
클래스는private
가시성을 갖기 때문에 컨버터를 작성할 수도 없네요. 프레임워크가 수정되어 이 녀석의.getPart()
가 호출되어 멤버 주입이 일어나면 좋겠습니다.아무튼 VO 객체에서 파일을 받을 멤버는
Part
가 아닌,MultipartFile
타입이어야 하는 점을 알아야 합니다.이렇게 5번째 TC의 의미에 대해 설명을 마칩니다.
테스트 결과 요약
테스트는 모두 통과합니다. 의미를 해석해 봅니다.
multiPartFileMap
가 사용되지 않고parts
가 사용된다.file()
를 쓰나part()
를 쓰나 관계 없다.assertThat(request.getMultiFileMap().size()).isEqualTo(0);
코드가 확인하는 사실이다.file()
로 파일을 담아도Part
객체가 받을 수 있다.part()
로 파일을 담아도MultipartFile
객체가 받아줄 수 있다.Part
이면 업로드 파일을 바인딩 해줄 수 없다!!!!@RequestPart
로 바인딩 가능함. 단,ObjectMapper
필요.챕터
94 #100