[Spring] Spring MockMvc 정리 (REST API 테스트, Multipart/form-data 테스트, 예외처리)
MockMvc란?
실제 객체와 비슷하지만 가짜 객체를 만들어 애플리케이션 서버에 배포하지 않고 Spring MVC 동작을 재현 할 수 있게 해주는 클래스이다.
pom.xml
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-all</artifactId>
<version>1.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.5.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.21.0</version>
</dependency>
Controller TestCode
class AdminControllerTest {
private PostRepository postRepository;
private AnswerRepository answerRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
postRepository = mock(PostRepository.class);
answerRepository = mock(AnswerRepository.class);
mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(postRepository, answerRepository)).build();
}
@Test
void adminHomeTest() throws Exception {
mockMvc.perform(get("/admin"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(model().attributeExists("posts"))
.andExpect(model().attribute("categories", Category.values()))
.andExpect(view().name("view/admin/index"));
}
@Test
void getDetailPost_notExistPost_thenPostNotFoundException() throws Exception {
long postId = 1234;
mockMvc.perform(get("/admin/detail/{postId}", postId))
.andDo(print())
.andExpect(status().isNotFound())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof PostNotFoundException));
}
}
@Test
void getDetailPost_success() throws Exception {
long postId = 1;
Post post = Post.create("testId", "testTitle", Category.Etc, "testContent");
post.setId(postId);
when(postRepository.exists(postId)).thenReturn(true);
when(postRepository.getPost(postId)).thenReturn(post);
mockMvc.perform(get("/admin/detail/{postId}", postId))
.andDo(print())
.andExpect(status().isOk())
.andExpect(model().attribute("post", post))
.andExpect(view().name("view/admin/detail"));
}
간단한 Controller 테스트코드이다.
mockMvc에는 단위 테스틀 위한 standaloneSetup()
과 통합 테스트를 위한 webAppContextSetup()
가 존재한다.
여기서는 통합 테스트를 위해 standaloneSetup()
을 이용하여 MockMvc
를 생성하여 사용 하였다.
MockMvc
에는 지정할 Controller를 넣어줄 수 있고, 그 안의 Repository
를 Mockito
를 이용하여 mock객체로 만들어 주입시켜 주었다.
(원래는 비지니스 로직은 Service 클래스를 작성하여 Service 객체가 Repository를 주입받아야 맞는 구조라고 알고 있지만, 현재 과정에서 Service객체 작성에 대한 언급과 교육이 아직 없어 위와 같이 작성 하였다.)
이때 addFilter()
또는 addInterceptor()
메서드를 통하여 Filter
혹은 Interceptor
또한 설정하여 테스트가 가능하다.
각종 MockMvc Standalone 설정은 공식문서를 통해 확인 할 수 있다.
위의 테스트 코드 작성에 쓰인 메서드 간단한 설명.
perform()
- 요청을 전송하는 역할을 한다.
- 리턴값으로
ResultActions
라는 객체를 받을 수 있다. ResultActions
실행된 요청의 결과를 예측할 수 있는 클래스로andDo()
,andExpect()
,andReturn
등이 있다.ResultActions
공식문서
get()
- HTTP Method를 정하는 메서드 이다.
MockMvcRequestBuilders
클래스 안에 속해 있는 메서드이다.- post, put, delete, multipart 등 다양한 HTTP Method를 설정 할 수 있다.
MockMvcRequestBuilders
공식문서
status()
,model()
,view()
ResultActions
에 의한 결과를 검증하는 메서드들이다.MockMvcResultMatchers
클래스 안에 속해 있는 메서드들이다.MockMvcResultmatchers
공식문서
REST API Test
class StudentRestControllerTest {
private MockMvc mockMvc;
private StudentRepository studentRepository;
private ObjectMapper objectMapper;
private StudentRestRequest testStudent;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
testStudent = new StudentRestRequest("test", "test@test.com", 90, "test comment");
studentRepository = mock(StudentRepository.class);
mockMvc = MockMvcBuilders.standaloneSetup(new StudentRestController(studentRepository))
.build();
}
@Test
void studentPost_json() throws Exception {
mockMvc.perform(post("/students")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testStudent)))
.andDo(print())
.andExpect(status().isCreated());
}
@Test
void studentGet_return_json() throws Exception {
long studentId = 1;
Student student = Student.create(
testStudent.getName(),
testStudent.getEmail(),
testStudent.getScore(),
testStudent.getComment()
);
when(studentRepository.getStudent(studentId)).thenReturn(student);
mockMvc.perform(get("/students/" + studentId)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("test"))
.andExpect(jsonPath("$.email").value("test@test.com"))
.andExpect(jsonPath("$.score").value(90))
.andExpect(jsonPath("$.comment").value("test comment"));
}
}
간단한 REST Controller에 대한 MockMvc를 이용한 테스트 코드이다.
위의 작성해 놓은 테스트 코드들과 거의 비슷하면 다른 점이이라면 jackson-databind
를 이용하여 데이터를 자바 객체가 아닌 JSON
형식으로 변환하여 넣어줘야 된다는 점이다.
또한 HTTP Request 헤더를 설정해 주어야 원하는 결과 값을 얻을 수 있다는 점이 다르다고 볼 수 있다.
multipart/form-data 이미지 업로드 Controller
이미지 업로드와 다운로드 두가지를 구현하였고, 그에 대해 작성 하려 한다.
Controller
@Slf4j
@Controller
@RequestMapping("/client/register")
public class PostRegisterController {
private static String UPLOAD_DIR = "/Users/gwanii/Downloads/";
private static List<String> acceptableFileType = List.of("image/gif","image/jpg","image/jpeg","image/png");
private final PostRepository postRepository;
public PostRegisterController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@PostMapping
public String doClientRegister(@Valid @ModelAttribute(value = "post") PostRegisterRequest postRequest,
@RequestParam(value = "uploadFiles", required = false) MultipartFile[] uploadFiles,
BindingResult bindingResult) throws IOException {
if (bindingResult.hasErrors()) {
throw new ValidationFailedException(bindingResult);
}
List<String> fileList = new ArrayList<>();
if (!fileEmptyCheck(uploadFiles)) {
fileUpload(uploadFiles, fileList);
}
Post post = postRepository.addPost(
postRequest.getAccountId(),
postRequest.getTitle(),
postRequest.getCategory(),
postRequest.getContent()
);
post.setFileList(fileList);
return "redirect:/client";
}
private boolean fileEmptyCheck(MultipartFile[] uploadFiles) {
boolean isEmpty = false;
for (MultipartFile file : uploadFiles) {
if (file.isEmpty()) {
isEmpty = true;
}
}
return isEmpty;
}
private void fileUpload(MultipartFile[] uploadFiles, List<String> fileList) throws IOException {
fileTypeCheck(uploadFiles);
for (MultipartFile file : uploadFiles) {
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
fileList.add(fileName);
file.transferTo(Paths.get(UPLOAD_DIR + fileName));
}
}
private void fileTypeCheck(MultipartFile[] uploadFiles) {
for (MultipartFile file : uploadFiles) {
if (!acceptableFileType.contains(file.getContentType())) {
throw new NotAcceptableFileTypeException();
}
}
}
}
흔한 게시글 작성 Controller 중 하나이다.
거기에 MultipartFile[]
을 이용하여 여러개의 파일을 받을 수 있게 하였다.
multipart/form-data test
class PostRegisterControllerTest {
private PostRepository postRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
postRepository = mock(PostRepository.class);
mockMvc = MockMvcBuilders.standaloneSetup(new PostRegisterController(postRepository)).build();
}
@Test
void doClientRegister_noFile_success() throws Exception {
MockMultipartFile emptyFile = new MockMultipartFile(
"uploadFiles",
"",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
"".getBytes()
);
Post post = Post.create("testId", "testTitle", Category.Etc, "testContent");
post.setId(1);
when(postRepository.addPost(
post.getAccountId(),
post.getTitle(),
post.getCategory(),
post.getContent())
).thenReturn(post);
mockMvc.perform(multipart("/client/register")
.file(emptyFile)
.param("accountId", "testId")
.param("title", "testTitle")
.param("category", "Etc")
.param("content", "testContent"))
.andDo(print())
.andExpect(status().isFound())
.andExpect(view().name("redirect:/client"));
}
@Test
void doClientRegister_existFile_invalidFileType_thenNotAcceptableFileTypeException() throws Exception {
String UPLOAD_DIR = "/Users/user/Downloads/";
String filePath = UPLOAD_DIR + "페페_img.png";
FileInputStream inputStream = new FileInputStream(filePath);
MockMultipartFile imgFile = new MockMultipartFile(
"uploadFiles",
"페페_img.png",
MediaType.TEXT_XML_VALUE,
inputStream
);
Post post = Post.create("testId", "testTitle", Category.Etc, "testContent");
post.setId(1);
when(postRepository.addPost(
post.getAccountId(),
post.getTitle(),
post.getCategory(),
post.getContent())
).thenReturn(post);
mockMvc.perform(multipart("/client/register")
.file(imgFile)
.param("accountId", "testId")
.param("title", "testTitle")
.param("category", "Etc")
.param("content", "testContent"))
.andDo(print())
.andExpect(status().isNotAcceptable())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof NotAcceptableFileTypeException));
}
@Test
void doClientRegister_existSingleFile_success() throws Exception {
String UPLOAD_DIR = "/Users/gwanii/Downloads/";
String filePath = UPLOAD_DIR + "페페_img.png";
FileInputStream inputStream = new FileInputStream(filePath);
MockMultipartFile imgFile = new MockMultipartFile(
"uploadFiles",
"페페_img.png",
MediaType.IMAGE_PNG_VALUE,
inputStream
);
Post post = Post.create("testId", "testTitle", Category.Etc, "testContent");
post.setId(1);
when(postRepository.addPost(
post.getAccountId(),
post.getTitle(),
post.getCategory(),
post.getContent())
).thenReturn(post);
mockMvc.perform(multipart("/client/register")
.file(imgFile)
.param("accountId", "testId")
.param("title", "testTitle")
.param("category", "Etc")
.param("content", "testContent"))
.andDo(print())
.andExpect(status().isFound())
.andExpect(view().name("redirect:/client"));
inputStream.close();
}
}
몇가지 다른 예외 처리 TestCode는 글이 너무 길어지기에 지웠다.
위와 같이 multipart()
메서드를 이용하여 HTTP 요청을 보낼 수 있고,param()
메서드를 통해 @RequestParam
혹은 @MModelAttributes
값들을 지정해 줄 수 있다.
또한 multipart/form-data에서 가장 중요한 file값이 문제이다.
Controller에서 @RequestParam(required = false)
를 주어 파일이 들어오지 않아도 게시글을 등록 할 수 있도록 만들었다.
하지만, 테스트에서는 파일값을 넣어주지 않으면 NullPointerException
이 터지게 된다.
그러므로 파일 입력값이 없는경우에도 MockMultipartFile
객체를 이용해 파일 객체를 생성해서 넣어주어야 된다.
참고로 내가 사용한 귀여운 페페 사진
파일 다운로드 Controller
@Slf4j
@Controller
public class DownloadController {
private static String FIlE_PATH = "/Users/user/Downloads/";
@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam("filename") String filename) throws IOException {
Path filePath = Paths.get(FIlE_PATH + filename);
if (!filePath.toFile().isFile()) {
throw new FileNotFoundException();
}
String mimeType = Files.probeContentType(filePath);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentDisposition(ContentDisposition.builder("attachment")
.filename(filename, StandardCharsets.UTF_8)
.build());
httpHeaders.add(HttpHeaders.CONTENT_TYPE, mimeType);
Resource resource = new InputStreamResource(Files.newInputStream(filePath));
return new ResponseEntity<>(resource, httpHeaders, HttpStatus.OK);
}
}
파일 다운로드 MockMvc Test
class DownloadControllerTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(new DownloadController()).build();
}
@Test
void doDownload_success() throws Exception {
String fileName = "페페_img.png";
mockMvc.perform(get("/download")
.param("filename", fileName))
.andDo(print())
.andExpect(status().isOk())
.andExpect(result -> assertThat(result.getResponse().getContentType()).isEqualTo("image/png"));
}
@Test
void doDownload_notExistFile_thenFileNotFoundException() throws Exception {
String fileName = "notExistFile.png";
Throwable th = catchThrowable(() ->
mockMvc.perform(get("/download")
.param("filename", fileName))
.andDo(print())
);
assertThat(th).isInstanceOf(FileNotFoundException.class);
}
}
다운로드 테스트 코드는 생각보다 별로 없었다.
아마 Controller내에 이미 비지니스 로직으로 HttpHeader
에 대한 설정들이 들어가 있기 때문이라 생각된다.
그렇기에 해당 HTTP 요청 후 HTTP Response Status 혹은 Content-type 정도의 검증을 해주었다.
밑의 예외처리의 경우 java의 Throwable
클래스로 Exception이 터지는 것을 잡아 Assertion
을 이용하여 검증하였다.
이상으로 MockMvc
를 직접 사용하며 작성하였던 테스트 코드를 보며 정리하는 부족한 글이었습니다.
'1.프로그래밍 > Java' 카테고리의 다른 글
[Spring] Spring 비밀번호 암호화 SHA-256 ~ BCryptPasswordEncoder(MessageDigest, SHA-256, BCryptPasswordEncoder) (0) | 2022.12.07 |
---|---|
[Spring] JPA 사용시 Entity Class Setter 메서드에 대한 고찰 (1) | 2022.12.01 |
[SpringBoot] IntelliJ Thymeleaf 자동 리로드(Live reload) (1) | 2022.09.25 |
[Spring Boot] Spring Data JPA 기초(코드로 배우는 스프링 부트 웹 프로젝트 ) (2) | 2022.09.25 |
[Java] Java Synchronized 란? (Java 동기화) (0) | 2022.09.11 |