본문 바로가기

1.프로그래밍/Java

[Spring] Spring MockMvc 정리 (REST API 테스트, Multipart/form-data 테스트)

728x90
반응형

[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를 넣어줄 수 있고, 그 안의 RepositoryMockito를 이용하여 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 객체를 이용해 파일 객체를 생성해서 넣어주어야 된다.

MockMultipartFile 공식문서



페페_img

참고로 내가 사용한 귀여운 페페 사진

파일 다운로드 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를 직접 사용하며 작성하였던 테스트 코드를 보며 정리하는 부족한 글이었습니다.

728x90
반응형