파일을 업로드하는 로직들을 모아 유틸 클래스로 만들고, 특정 서비스에서 파일 업로드 유틸 클래스를 참조하여 메소드를 호출할 때 해당 메소드를 테스트하려면 바로 다음과 같은 문제에 직면합니다. 테스트 코드에서는 파일 업로드를 할 수 없는데 유틸 클래스에서 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 객체를 사용하면 됩니다.