TDD 연습은 오늘도 계속된다. 지난 금요일 점심 때 살짝 작업해 본 코드를 GitHub에 커밋하는 걸 깜빡해서 주말에 진행하지 못했다. 음, 그덕에 오늘 밤에 진행한다.
Job에 새로운 모델 추가
오늘은 Job에 모델을 추가해볼 거다. 비디오 변환을 처리하면 변환된 결과 비디오 파일과 썸네일 파일이 생성되는데, 그 파일을 작업 요청자가 지정한 위치에 저장해야 한다. 이 위치는 FTP가 될 수도 있고, HTTP 파일 업로드가 될 수도 있고, 단순히 로컬 파일 시스템이 될 수도 있다. 자, 일단, 구현의 상세함은 뒤로 미루고 다음과 같이 결과 파일이 보관될 위치를 추상화해서 표현할 수 있을 것이다.
public class Job {
private Long id;
private MediaSourceFile mediaSourceFile;
private DestinationStorage destinationStorage;
private State state;
private Exception occurredException;
public Job(Long id, MediaSourceFile mediaSourceFile,
DestinationStorage destinationStorage) {
this.id = id;
this.mediaSourceFile = mediaSourceFile;
this.destinationStorage = destinationStorage;
}
뭔가 이름이 적당한 게 떠오르지 않는다. (이럴 땐 영어공부를 게을리한 필자가 안타깝다.) 하튼, 이름은 DestinationStorage로 가기로 했다. 나중에 더 좋은 이름이 떠오르면 이클립스의 힘을 빌려 쉽게 바꿀 수 있다.
새로운 타입이 출현했다. 아무것도 없으므로 컴파일 에러가 난다. DestinationStorage 타입을 생성해주자. 그리고, Job 클래스의 생성자가 변경되었으므로 테스트 코드에서 Mock을 이용해서 DestinationStorage를 전달해준다.
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
private Long jobId = new Long(1);
@Mock
private MediaSourceFile mediaSourceFile;
@Mock
private DestinationStorage destinationStorage;
private Job mockJob;
...
@Before
public void setup() {
mockJob = new Job(jobId, mediaSourceFile, destinationStorage);
when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);
transcodingService = new TranscodingServiceImpl(transcoder,
thumbnailExtractor, createdFileSender, jobResultNotifier,
jobRepository);
바꿨으니 당연히 뭘 해야 하는지 알 거다. 테스트를 실행해보자. 기존 코드에 문제가 없음을 확인한다.
앞서 Job에 MediaSourceFile을 추가했던 과정을 생각해보자. (기억이 안 나면 http://javacan.tistory.com/221 글을 다시 보자.) 그 과정과 마찬가지로 다음과 같은 작업을 진행할 것이다.
- 목적지를 알고 있는 것은 DestinationStorage이므로, Job이 DestinationStorage에 파일을 저장해 달라고 요청하도록 코드를 수정한다.
- 이렇게 되면 CreatedFileSaver가 필요없지므로 삭제한다.
createdFileSaver를 호출하는 코드는 필요없으므로 삭제한다. destinationStorage는 save 메서드가 없어서 컴파일 에러가 난다. 컴파일 에러가 나지 않도록 save() 메서드를 추가해 준다. 이클립스의 만능키 Ctrl+1을 누루면 바로 추가되므로 쉽게 컴파일 에러를 제거할 수 있다.
public interface DestinationStorage {
void save(List<File> multimediaFiles, List<File> thumbnails);
}
오케이 일단 뭔가 바꿨다. 테스트 코드를 돌려보자. .... 빨간불. 통과에 실패했다. 원인은 아래와 같다.
Wanted but not invoked:
createdFileSender.store([], [], 1);
-> at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)
at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)
...
실패 원인은 createdFileSender.store()가 호출되지 않은 것 때문이다. 앞서 Job 클래스의 storeCreatedFilesToStorage() 메서드에서 createFileSaver 객체의 store() 메서드를 호출하는 부분을 삭제했기 때문에 발생한 것이다. 이제 이 부분은 createdFileSender가 아니라 destinationStorage.save() 메서드가 호출되었는지 검증하는 코드로 바꿔야 한다. 그래서, 아래와 같이 테스트 코드에서 협업 객체들의 메서드 호출 여부를 검증하는 부분을 변경하였다.
private void verifyCollaboration(VerifyOption verifyOption) {
if (verifyOption.transcoderNever)
verify(transcoder, never()).transcode(any(File.class), anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (verifyOption.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (verifyOption.destinationStorageNever) // createdFileSaverNever를 이름 변경
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (verifyOption.createdFileSenderNever)
verify(createdFileSender, never()).store(anyListOf(File.class),
anyListOf(File.class), anyLong());
else
verify(createdFileSender, only()).store(mockMultimediaFiles,
mockThumbnails, jobId);
if (verifyOption.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
destinationStorage Mock 객체의 save() 메서드가 호출되는 지의 여부로 변경했으므로, 다시 테스트를 실행해 보자. 6개의 테스트 메서드 중에서 1개가 실패가 난다. 실패한 테스트는 아래 메서드이다.
@Test
public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
doThrow(mockException).when(createdFileSender).store(
mockMultimediaFiles, mockThumbnails, jobId);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
private void executeFailingTranscodeAndAssertFail(State expectedLastState) {
try {
transcodingService.transcode(jobId);
fail("발생해야 함"); // 익셉션이 발생하지 않아서 실패
} catch (Exception ex) {
assertSame(mockException, ex);
}
...
}
검증하는 코드에서는 익셉션이 발생해야 통과되는데, Job 객체를 수정하는 바람에 더 이상 createdFileSaver 객체가 호출되지 않게 되었다. 위 코드는 createdFileSaver 대신 새롭게 추가된 모델인 destinationStorage의 save() 메서드에서 익셉션을 발생하도록 수정해주면 통과 시킬 수 있을 것 같다. 메서드 이름도 함께 변경해 주자.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
다시 테스트 코드 실행. 모두 녹색. 통과다.
이제 필요 없어진 코드가 있다. 바로 CreatedFileSaver 이다. 이제 이 타입을 사용하는 코드를 모두 과감하게 삭제한다. (주석으로 막아서 테스트를 실행하고 통과되면 지우는 방법을 점진적으로 삭제한다.)
Job 클래스의 작은 리팩토링
필자와 개인적으로 친분이 있는 분께서 http://javacan.tistory.com/221#comment9371876와 같은 의견을 주셨다. 그 중 첫 번째 Job 클래스 부분에 대한 피드백은 아래 코드에서 구조적인 반복이 나타난다는 점이었다. 상태를 변경하고 작업 실행하고, 또 상태를 변경하고 작업을 실행하는 구조를 갖고 있다.
public void transcode(Transcoder transcoder,
ThumbnailExtractor thumbnailExtractor,
JobResultNotifier jobResultNotifier) {
try {
changeState(Job.State.MEDIASOURCECOPYING);
File multimediaFile = copyMultimediaSourceToLocal();
changeState(Job.State.TRANSCODING);
List<File> multimediaFiles = transcode(multimediaFile, transcoder);
changeState(Job.State.EXTRACTINGTHUMBNAIL);
List<File> thumbnails = extractThumbnail(multimediaFile,
thumbnailExtractor);
changeState(Job.State.STORING);
storeCreatedFilesToStorage(multimediaFiles, thumbnails);
changeState(Job.State.NOTIFYING);
notifyJobResultToRequester(jobResultNotifier);
changeState(Job.State.COMPLETED);
} catch (RuntimeException ex) {
exceptionOccurred(ex);
throw ex;
}
}
우선, 상태 변경과 작업 실행 순서 관리를 transcode()가 처리하고 있는데, 이는 transcode()의 역할을 잘못 잡은 것 같다. 실제 상태의 변화는 각각의 메서드에서 처리하고 transcode() 메서드는 순서대로 작업이 실행되도록 하는 것이 적당한 역할을 수행하는 것으로 생각된다. 그래서, 상태 변경은 각 메서드에서 처리하도록 수정했다. 그리고 완료 상태로 변경하는 부분은 completed()라는 메서드를 만들어서 그 메서드에서 처리하도록 했다.
public class Job {
...
public void transcode(Transcoder transcoder,
ThumbnailExtractor thumbnailExtractor,
JobResultNotifier jobResultNotifier) {
try {
File multimediaFile = copyMultimediaSourceToLocal();
List<File> multimediaFiles = transcode(multimediaFile, transcoder);
List<File> thumbnails = extractThumbnail(multimediaFile,
thumbnailExtractor);
storeCreatedFilesToStorage(multimediaFiles, thumbnails);
notifyJobResultToRequester(jobResultNotifier);
completed();
} catch (RuntimeException ex) {
exceptionOccurred(ex);
throw ex;
}
}
private File copyMultimediaSourceToLocal() {
changeState(Job.State.MEDIASOURCECOPYING);
return mediaSourceFile.getSourceFile();
}
private List<File> transcode(File multimediaFile, Transcoder transcoder) {
changeState(Job.State.TRANSCODING);
return transcoder.transcode(multimediaFile, id);
}
private List<File> extractThumbnail(File multimediaFile,
ThumbnailExtractor thumbnailExtractor) {
changeState(Job.State.EXTRACTINGTHUMBNAIL);
return thumbnailExtractor.extract(multimediaFile, id);
}
private void storeCreatedFilesToStorage(List<File> multimediaFiles,
List<File> thumbnails) {
changeState(Job.State.STORING);
destinationStorage.save(multimediaFiles, thumbnails);
}
private void notifyJobResultToRequester(JobResultNotifier jobResultNotifier) {
changeState(Job.State.NOTIFYING);
jobResultNotifier.notifyToRequester(id);
}
private void completed() {
changeState(Job.State.COMPLETED);
}
}
이제 Job은 작업을 순서대로 실행하는 한 가지 일만을 책임지게 되었으므로 정리가 된 느낌이다. 하지만, 여전히 각각의 메서드들은 상태변경-작업실행 요청의 반복적 구조를 갖고 있다. 이런 구조를 제거할 수 있는 다른 방법이 있을 것도 같다. 예를 들어, 각 단계를 State 같은 걸로 추상화하고 State가 지정된 순서대로 전이되도록 StateChain 같은 걸 만들어서 지정한 순서대로 각 단계의 작업이 실행되도록 할 수 있을 것 같다. 하지만, 이런 구조를 갖는 것은 현재로서는 유연함에서 오는 이점보다 복잡함에서 오는 단점이 더 많은 것 같다.
그래서, 위의 구조적 반복이 여전히 남아 있긴 하지만, 일단 이쯤에서 정리한다. 혹시 Job의 하위 타입이 생긴다거나 새로운 상태가 생긴다거나 실행 순서의 변경이 발생하게 된다면, 그 때 가서 다시 리팩토링을 해 보도록 하자. (이 정도로 지인분의 요구가 충족될지는........ 이 정도로 만족하시지요!)
테스트의 VerifyOption에 기능 주기
지인분께서 http://javacan.tistory.com/221#comment9371876에 남기신 두 번째 피드백은 verifyOption이 검증과 관련된 정보를 갖고 있으니, VerifyOption을 단순 데이터 구조가 아닌 기능을 제공하는 객체로 만들어보자는 내용이다. 음, 이 부분은 고민이 좀 된다. 테스트 코드에서 사용되는 데이터인데 그것까지 객체로 만들 필요가 있을까 하는 고민이었다. 그래도 변경하는게 어렵지 않으니 바꿔보자.
뭔가 협업 객체가 올바르게 호출되는지 여부를 확인하는 코드는 아래와 같은 방식으로 사용된다.
@Test
public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
mockException);
executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.thumbnailExtractorNever = true;
verifyOption.destinationStorageNever = true;
verifyOption.jobResultNotifierNever = true;
verifyCollaboration(verifyOption);
}
private void verifyCollaboration(VerifyOption verifyOption) {
if (verifyOption.transcoderNever)
verify(transcoder, never()).transcode(any(File.class), anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (verifyOption.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (verifyOption.destinationStorageNever)
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (verifyOption.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
public class VerifyOption {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
}
}
우선, verifyCollaboration 메서드를 VerifyOption의 public 메서드로 이동시킨다. 물론, 옮기면서 this를 사용하도록 변경한다. 그리고, 각 테스트 메서드에서는 VerifyOption의 verifyCollaboration() 메서드를 호출하도록 변경한다. 그럼 코드는 아래와 같은 바뀐다.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
VerifyOption verifyOption = new VerifyOption();
verifyOption.jobResultNotifierNever = true;
verifyOption.verifyCollaboration();
}
public class VerifyOption {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
public void verifyCollaboration() {
if (this.transcoderNever)
verify(transcoder, never()).transcode(any(File.class),
anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
if (this.thumbnailExtractorNever)
verify(thumbnailExtractor, never()).extract(any(File.class),
anyLong());
else
verify(thumbnailExtractor, only()).extract(mockMultimediaFile,
jobId);
if (this.destinationStorageNever)
verify(destinationStorage, never()).save(anyListOf(File.class),
anyListOf(File.class));
else
verify(destinationStorage, only()).save(mockMultimediaFiles,
mockThumbnails);
if (this.jobResultNotifierNever)
verify(jobResultNotifier, never()).notifyToRequester(jobId);
else
verify(jobResultNotifier, only()).notifyToRequester(jobId);
}
}
바꿨으니 테스트 실행이다. 녹색! 통과되었다.
이름이 마음에 안 든다. 이름을 바꾸자. VerifyOption은 더 이상 단순 옵션이 아니다. 실제 검증을 수행하는 역할을 갖게 되었으므로, CollaborationVerifier와 같이 역할에 맞는 이름을 부여한다. verifyCollaboration() 메서드는 순히 verify()로 바꿔도 될 것 같다. 웃, verify()로 이름을 변경하려고 했더니 메서드 내부에서 사용한 Mockito의 verify()가 컴파일 에러가 난다. 음, 다른 이름을 찾아보자. 음.. 딱히 이름이 안 떠오른다. 일단 verifyCollaboration()으로 놔두자.
@Test
public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {
doThrow(mockException).when(destinationStorage).save(
mockMultimediaFiles, mockThumbnails);
executeFailingTranscodeAndAssertFail(Job.State.STORING);
CollaborationVerifier colVerifier = new CollaborationVerifier();
colVerifier.jobResultNotifierNever = true;
colVerifier.verifyCollaboration();
}
public class CollaborationVerifier {
public boolean transcoderNever;
public boolean thumbnailExtractorNever;
public boolean destinationStorageNever;
public boolean jobResultNotifierNever;
public void verifyCollaboration() {
if (this.transcoderNever)
verify(transcoder, never()).transcode(any(File.class),
anyLong());
else
verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
...
}
}
}
이름을 바꿨으니 다시 테스트! 녹색! 통과되었다.
CollaborationVerifier와 관련된 다음 고민은 아래와 같다.
- transcoderNever, thumbnailExtractorNever 등의 필드를 private으로 바꿔야 하나?
- verifyCollaboration() 메서드에서 접근하는 transcoder 등의 객체를 필드로 정의해야 하나?
- CollaborationVerifier 클래스를 private으로 바꿔야 하나?