본문 바로가기
Spring

테스트 코드 작성 시 파일 업로드를 하지 않고 MultipartFile 가져오기

by 코더 제이콥 2023. 7. 10.

파일을 업로드하는 로직들을 모아 유틸 클래스로 만들고, 특정 서비스에서 파일 업로드 유틸 클래스를 참조하여 메소드를 호출할 때 해당 메소드를 테스트하려면 바로 다음과 같은 문제에 직면합니다. 테스트 코드에서는 파일 업로드를 할 수 없는데 유틸 클래스에서 MultipartFile 객체의 의존성이 있는 경우입니다.

이번 포스팅에서는 해당 내용을 다뤄보겠습니다.

1. 코드

코드를 보여드리기 전, 대충 코드의 로직을 설명해드리자면 도서를 등록할 때 thumbnail 이미지를 같이 등록해야 하는데 이때 파일 업로드 클래스를 호출하여 로직을 처리하고 있습니다.

도서 그룹 관리 컨트롤러

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/manage/book-group")
public class BookGroupManagementController {

    private final BookGroupManagementService bookGroupManagementService;

    @PostMapping
    public ResponseEntity<Object> registerCategoryGroup(BookGroupRegisterRequest request,
                                                        MultipartFile file){
        bookGroupManagementService.registerBookGroup(request, file);

        BaseResponse response = new BaseResponse(HttpStatus.CREATED, "등록이 완료되었습니다.", true);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(response);
    }
}

 

도서 그룹 서비스

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class BookGroupManagementService {

    private final BookGroupRepository bookGroupRepository;
    private final ImageUploadUtil imageUploadUtil;

    public Long registerBookGroup(BookGroupRegisterRequest request, MultipartFile file) {
        String title = request.getTitle();
        if(title == null || title.trim().equals("")){
            throw new IllegalArgumentException("도서 그룹명을 입력해주세요.");
        }

        String savedImageName = imageUploadUtil.uploadImage(file);

        BookGroup bookGroup = BookGroup.createBookGroup(request, savedImageName);
        return bookGroupRepository.save(bookGroup).getId();
    }
}

사실 컨트롤러에서 이미지 업로드 클래스와 도서 그룹 서비스를 두 번 호출할지, 도서 그룹 서비스에서 업로드 클래스를 호출할 지 고민했었습니다. 컨트롤러에서 두 번 작업함으로써 도서 그룹 서비스의 도서 등록 메소드(registerBook)는 MultipartFile 객체를 의존하고 싶지 않아서였습니다.

하지만 생각이 바뀌었습니다. 이미지 업로드와 도서 그룹 등록은 하나의 트랜잭션이어야만 합니다. 이미지 업로드가 실패할 때 도서 그룹 등록은 이뤄지지 않아야 하고, 이미지 업로드가 성공해도 도서 그룹 등록이 실패하면 이미지는 업로드되지 않아야 합니다. 따라서 하나의 트랜잭션으로 묶었습니다.


이미지 업로드 클래스

public interface ImageUploadUtil {
    String UPLOAD_PATH = "D:/file/";

    String uploadImage(MultipartFile file);

    String updateImage(MultipartFile file, String existingImageName);

    void deleteImage( String savedImageName);
}

@Service
public class ImageUploadUtilImpl implements ImageUploadUtil{

    @Override
    public String uploadImage(MultipartFile file) {
        String fileName = createFileName();
        String fileExtension = extractExtension(file.getOriginalFilename());
        String filePath = getFilePath(UPLOAD_PATH, fileName, fileExtension);

        File saveFile = new File(filePath);
        try {
            file.transferTo(saveFile);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return fileName;
    }

    @Override
    public String updateImage(MultipartFile file, String existingImageName) {
        deleteImage(existingImageName);

        return uploadImage(file);
    }

    @Override
    public void deleteImage(String savedImageName) {
        File existFile = new File(savedImageName);
        existFile.delete();
    }

    private String createFileName() {
        return UUID.randomUUID().toString();
    }


    private String extractExtension(String fileName) {
        int index = fileName.lastIndexOf(".");
        return fileName.substring(index + 1);
    }

    private String getFilePath(String uploadPath, String fileName, String fileExtension) {
        return uploadPath + fileName + "." + fileExtension;
    }
}

도서 그룹 엔티티

@Entity
@Getter
public class BookGroup extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "book_group_id")
    private Long id;
    private String title;
    private String savedImageName;

    public static BookGroup createBookGroup(BookGroupRegisterRequest request, String savedImageName) {
        BookGroup bookGroup = new BookGroup();
        bookGroup.title = request.getTitle();
        bookGroup.savedImageName = savedImageName;
        return bookGroup;
    }
}

2. 문제점

위에서 언급한 것처럼, 도서 그룹 등록 과정과 이미지 등록을 하나의 트랜잭션으로 묶는 바람에 도서 그룹 등록을 테스트할 때 이미지 업로드에 필요한 MultipartFile 객체 의존성을 추가해야 합니다. 실제 파일을 등록하지 않고 어떻게 테스트 코드를 작성할 수 있을까요? 이때 사용할 수 있는 것이 MockMultipartFile 객체입니다.
https://www.baeldung.com/spring-multipart-post-request-test

MockMultipartFile file = new MockMultipartFile(
        "홍길동전 썸네일 이미지",
        "thumbnail.png",
        MediaType.IMAGE_PNG_VALUE,
        "thumbnail".getBytes()
);

생성자 첫 번째 필드에는 파일의 이름을, 두 번째 필드에는 오리지널 파일 이름을, 세 번째 필드에는 미디어 타입을, 네 번째 필드에는 바이트를 입력합니다. 아래는 제가 작성한 테스트 코드입니다.

@Transactional
@SpringBootTest
class BookGroupManagementServiceTest {
    @Autowired
    private BookGroupRepository bookGroupRepository;
    private BookGroupManagementService bookGroupManagementService;
    private ImageUploadUtil imageUploadUtil;

    @BeforeEach
    void beforeEach(){
        bookGroupManagementService = getBookGroupManagementService();
    }

    @Test
    void whenTitleIsNullOrBlank_thenThrowException(){
        MockMultipartFile file = getMockMultipartFile();

        BookGroupRegisterRequest nullRequest = new BookGroupRegisterRequest(null);

        assertThatThrownBy(() -> bookGroupManagementService.registerBookGroup(nullRequest, file))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("도서 그룹명을 입력해주세요.");

        BookGroupRegisterRequest blankRequest = new BookGroupRegisterRequest("");

        assertThatThrownBy(() -> bookGroupManagementService.registerBookGroup(blankRequest, file))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("도서 그룹명을 입력해주세요.");
    }

    @Test
    void whenBookGroupRegistered_thenVerifyIsRegistered(){
        MockMultipartFile file = getMockMultipartFile();
        BookGroupRegisterRequest request = new BookGroupRegisterRequest("홍길동전");

        Long bookGroupId = bookGroupManagementService.registerBookGroup(request, file);

        BookGroup bookGroup = bookGroupManagementService.findBookGroupById(bookGroupId);

        assertThat(bookGroup.getId()).isEqualTo(bookGroupId);
        assertThat(bookGroup.getTitle()).isEqualTo("홍길동전");
        verify(imageUploadUtil).uploadImage(file);
    }

    private BookGroupManagementService getBookGroupManagementService() {
        imageUploadUtil = mock(ImageUploadUtil.class);
        BookGroupManagementService bookGroupManagementService = new BookGroupManagementService(bookGroupRepository, imageUploadUtil);
        return bookGroupManagementService;
    }

    private static MockMultipartFile getMockMultipartFile() {
        return new MockMultipartFile(
                "홍길동전 썸네일 이미지",
                "thumbnail.png",
                MediaType.IMAGE_PNG_VALUE,
                "thumbnail".getBytes()
        );
    }

}

 

하나하나 살펴보겠습니다.

@Autowired
private BookGroupRepository bookGroupRepository;

BookGroupManagementService는 각각 BookGroupRepository와 ImageUploadUtil 클래스가 필요합니다. 따라서 BookGroupRepository는 @Autowired로 의존성 주입을 했습니다.

private BookGroupManagementService bookGroupManagementService;
private ImageUploadUtil imageUploadUtil;

@BeforeEach
void beforeEach(){
    bookGroupManagementService = getBookGroupManagementService();
}

private BookGroupManagementService getBookGroupManagementService() {
    imageUploadUtil = mock(ImageUploadUtil.class);
    BookGroupManagementService bookGroupManagementService = new BookGroupManagementService(bookGroupRepository, imageUploadUtil);
    return bookGroupManagementService;
}

 

먼저 필드에 private으로 BookGroupManagementService와 ImageUploadUtil을 선언했습니다. 이 클래스의 초기 상태는 null일 것입니다.

@BeforeEach 어노테이션은 @Test 어노테이션이 붙은 테스트가 실행하기 전에, 먼저 메소드가 실행되도록 설정합니다. 해당 메소드에서는 null인 필드들을 초기화하는 작업을 합니다.

첫 번째로 이미지 업로드 유틸 클래스를 초기화했습니다. 해당 클래스는 인터페이스인데 Mockito.mock(ImageUploadUtil.class)를 통해 가짜 객체로 인터페이스를 구현했습니다. 위 코드에서 Mockito가 생략된 이유는 스태틱 임포트를 걸었기 때문입니다. 

두 번째로 도서 그룹 서비스의 생성자에 각각 @Autowired로 초기화한 리포지토리와 이미지 유틸 클래스를 삽입함으로써 도서 그룹 서비스를 초기화했습니다.

private static MockMultipartFile getMockMultipartFile() {
    return new MockMultipartFile(
            "홍길동전 썸네일 이미지",
            "thumbnail.png",
            MediaType.IMAGE_PNG_VALUE,
            "thumbnail".getBytes()
    );
}

멀티 파트 파일을 가져오는 유틸리티 메소드입니다. 테스트 코드마다 생성자로 초기화하기 귀찮아서 메소드로 뺐습니다.

verify(imageUploadUtil).uploadImage(file);

Mockito.verify() 메소드는 해당 클래스의 메소드가 호출되었는지 확인할 수 있는 메소드입니다.

verify 메소드 알아보기(이메일 전송 테스트 시 이메일 전송하지 않고 테스트 작성하는 방법)
https://jaykaybaek.tistory.com/23

3. 결론

테스트코드 작성 시 MultipartFile 객체의 의존성이 생긴 경우 MockMultipartFile 객체를 사용하면 됩니다.