저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

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 글을 다시 보자.) 그 과정과 마찬가지로 다음과 같은 작업을 진행할 것이다.

  1. 목적지를 알고 있는 것은 DestinationStorage이므로, Job이 DestinationStorage에 파일을 저장해 달라고 요청하도록 코드를 수정한다.
  2. 이렇게 되면 CreatedFileSaver가 필요없지므로 삭제한다.
자 일단 Job이 DestinationStorage에게 파일을 저장해달라고 요청하도록 코드를 수정한다.

public class Job {

    private Long id;
    private MediaSourceFile mediaSourceFile;
    private DestinationStorage destinationStorage;
    ...
    public void transcode(Transcoder transcoder,
            ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier) {
        try {
            ...
            changeState(Job.State.STORING);
            storeCreatedFilesToStorage(multimediaFiles, thumbnails,
                    createdFileSaver);
            changeState(Job.State.NOTIFYING);
            notifyJobResultToRequester(jobResultNotifier);
            changeState(Job.State.COMPLETED);
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

    private void storeCreatedFilesToStorage(List<File> multimediaFiles,
            List<File> thumbnails, CreatedFileSaver createdFileSaver) {
        destinationStorage.save(multimediaFiles, thumbnails);
        createdFileSaver.store(multimediaFiles, thumbnails, id);
    }

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으로 바꿔야 하나?
음, 아주 짧게만 고민해 봤는데 (슬슬 졸리기 시작한다), 현재로서 CollaborationVerifier 클래스는 TranscodingServiceImplTest 내부에서만 사용되므로, 필드가 public이어도 상관없을 것 같고 TranscodingServiceImplTest 클래스의 필드에 직접 접근해도 문제가 없다. 일단 지금은 CollaborationVerifier를 private으로 하는 것만으로 충분할 것 같다.


Posted by 최범균 madvirus

댓글을 달아 주세요

* TDD 과정을 공유하기 위해 연습하는 과정을 글로 남긴다.


실패 상황 시나리오 구현하기


앞서 http://javacan.tistory.com/entry/TDD-exercise-1-Design-communication-using-tdd 글에 이어 실패 상황을 구현해 보자. 우선 원본 소스로부터 파일을 복사해 오는 과정이 실패났을 경우를 가정해서 테스트 코드를 만든다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    private Long jobId = new Long(1);


    @Mock

    private MediaSourceCopier mediaSourceCopier;

    ....

    private TranscodingService transcodingService;


    @Test

    public void transcodeSuccessfully() {

        ....

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.isLastState());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, never()).transcode(any(File.class), anyLong());

        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());

        verify(createdFileSender, never()).store(anyListOf(File.class),

                anyListOf(File.class), anyLong());

        verify(jobResultNotifier, never()).notifyToRequester(jobId);

    }

}


위 테스트 메서드는 MediaSourceCopier가 copy 과정에서 문제가 발생한 경우 그걸 검증하는 코드로 작성되었다. 먼저 transcodingService.transcode() 메서드는 익셉션을 발생시켜야 한다. 그렇지 않을 경우 테스트가 실패하도록 했다. 그리고, mediaSourceCopier를 제외한 나머지 협업 객체는 호출되어서는 안 되므로, 호출 검증 값을 never()로 지정하였다.


드디어 Job과 jobRepository가 출현했다. 먼저 연습 1에서 했던 것 처럼, jobRepository를 필드로 만들고 JobRepository 인터페이스를 생성한다. 그리고, Job 클래스도 함께 만들어준다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    ...

    @Mock

    private JobRepository jobRepository;

    

    private TranscodingService transcodingService;

    ...

    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ...

    }

}


컴파일 에러다. 컴파일 에러가 나지 않도록 다음과 같이 메서드/enum 타입 등을 추가해 주자.


public interface JobRepository {

    Job findById(Long jobId);

}



public class Job {


    public static enum State {

        MEDIASOURCECOPYING

    }


    public boolean isSuccess() {

        return false;

    }


    public State getLastState() {

        return null;

    }


}


컴파일 에러를 제거했으므로 테스트를 실행한다. 테스트가 실패한다. 원인은 다음과 같다.


java.lang.AssertionError: 발생해야 함

at org.junit.Assert.fail(Assert.java:91)

at org.chimi.s4t.domain.transcode.TranscodingServiceImplTest.transcodeFailBecauseExceptionOccuredAtMediaSourceCopier(TranscodingServiceImplTest.java:74)

at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

        ...


아! transcodingService.transcode() 메서드가 익셉션을 발생시켜야 하는데, 무사히 실행되어서 fail()에 걸렸다. 이 테스트는 원본 미디어 파일을 복사해 오는 도중에 에러가 발생한 경우를 가정한 것이므로 mediaSourceCopier Mock 객체가 익셉션을 발생시키도록 하자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isDone());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ...

    }

    



다시 테스트 실행.... 빨간색이다. NullPointerException이다. 스택트레이스를 보니 아래 줄에서 job이 null이어서 발생한 것이다.


assertFalse(job.isSuccess());


jobRepository가 Job 객체를 리턴하도록 Mock 객체를 설정하자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());



다시 테스트 실행. 에러가 났다. job.getLastState()가 MEDIASOURCECOPYING이 아니라는 에러다. 그런데, job.isSuccess()가 false인지 확인하는 코드는 통과되었다. 이런! 잠시 고민하다가, 앞에서 작성했었던 transcodeSuccessfully() 테스트에 다음과 같이 Job에 대한 검증 코드를 추가하였다.



    @Test

    public void transcodeSuccessfully() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        ...

        transcodingService.transcode(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isSuccess());

        assertEquals(Job.State.COMPLETED, job.getLastState());

        ... // Mock 객체들의 상호작용 확인

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ... // Mock 객체들의 상호작용 확인

    }


Job.State enum 타입에 COMPLETED를 추가한 뒤에 다시 테스트를 실행해 보자. 에러다! 두 테스트 메서드에서 모두 실패가 발생했다.

  • transcodeSuccessfully() 메서드의 실패 지점
    • assertTrue(job.isSuccess()) 
    • 아직 assertEquals(Job.State.COMPLETED, job.getLastState()) 는 확인도 못한다.
  • transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 메서드의 실패 지점
    • assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

이제 이 두 테스트 메서드가 모두 통과되도록 TranscodingServiceImpl을 수정해 보자.


TranscodingServiceImpl 구현 고민 그리고 새로운 협업 객체 등장


테스트를 통과시키기 위해서 TranscodingServiceImpl 클래스에 다음의 처리를 해야 한다.

  • 성공적으로 실행되면 Job의 상태를 COMPLETED로 바꾸고 성공 여부를 true로 지정한다.
  • 원본 파일 복사 전에 Job의 상태를 MEDIASOURCECOPYING로 바꾸고, 복사 과정 중 익셉션이 발생하면 Job의 성공 여부를 false로 지정한다.
위의 내용을 보면 뭔가 Job의 상태를 변경해주는 기능이 필요해 보인다. 또한, 익셉션이 발생하면 에러 상태로 변경해 주어야 할 것 같기도 하다.

우선, TranscodingServceImpl 클래스에 상태를 변경해주는 기능을 아래와 같이 별도의 메서드인 changeJobState()로 추상화하고, transcode()는 그 메서드를 호출하도록 했다.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThumbnail(multimediaFile, jobId);
        storeCreatedFilesToStorage(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);
        changeJobState(jobId, Job.State.COMPLETED);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState); // 컴파일 에러
    }


위 코드에서 changeJobState() 메서드는 Job의 상태 변경 처리를 jobStateChanger 객체에 위임하고 있다. (이런 위임은 이미 copyMultimediaSourceToLocal() 메서드나 extractThumbnail() 메서드 등에서 동일하게 사용하고 있으며, 위임을 하면 Mock 객체를 사용해서 빠르게 테스트를 통과시킬 수 있게 된다.) jobStateChange 필드가 없어서 컴파일 에러가 나므로, jobStateChanger 필드를 추가하고 JobStateChanger 인터페이스를 만들고, 그 인터페이스를 changeJobState() 메서드를 정의해서 컴파일 에러를 제거한다. 또한, 생성자를 통해서 JobStateChanger 객체를 전달받도록 수정하자.

public class TranscodingServiceImpl implements TranscodingService {
    private MediaSourceCopier mediaSourceCopier;
    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;
    private CreatedFileSaver createdFileSaver;
    private JobResultNotifier jobResultNotifier;
    private JobStateChanger jobStateChanger;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier, JobStateChanger jobStateChanger) {
        ...
        this.jobStateChanger = jobStateChanger;
    }

    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThumbnail(multimediaFile, jobId);
        storeCreatedFilesToStorage(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

생성자가 수정되었으므로, 기존에 생성자를 사용한 테스트 코드에서 컴파일 에러가 발생한다. 컴파일 에러가 발생하지 않도록 JobStateChanger에 대한 Mock 객체를 만들고, 생성자에 전달해주도록 하자.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private JobRepository jobRepository;
    @Mock
    private JobStateChanger jobStateChanger;

    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger);
    }

새로운 협업 객체가 생겼다. 이제 첫 번째로 할 작업은 정상적으로 실행된 경우의 테스트를 통과시키는 것이다. 이를 위해 해야 할 작업은 JobStateChanger의 Mock 객체가 알맞게 Job의 상태를 변경시키도록 하는 것이다. 아래 코드는 테스트를 통과시키기 위해 Mock 객체에 기능을 부여한 코드이다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private JobStateChanger jobStateChanger;

    private Job mockJob = new Job();
    
    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger);

        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                Job.State newState = (State) invocation.getArguments()[1];
                mockJob.changeState(newState);
                return null;
            }
        }).when(jobStateChanger).chageJobState(anyLong(), any(Job.State.class));
    }

    @Test
    public void transcodeSuccessfully() {
        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        ...
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isSuccess());
        assertEquals(Job.State.COMPLETED, job.getLastState());
        ...
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        ...
        Job job = jobRepository.findById(jobId);
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        ...
    }
}

위 코드에서 주의할 점은 Job 객체를 mockJob으로 필드로 빼고, 각 테스트 메서드에서 jobRepository Mock 객체의 findById(jobId) 메서드가 호출되면 mockJob을 리턴하도록 구현했다는 것이다.

Job 클래스에 changeState() 메서드가 없어서 컴파일 에러가 발생하고 있으므로, changeState() 메서드를 구현할 차례이다. 이 메서드를 알맞게 구현해서 위 테스트가 통과되도록 만들면 된다. 통과시키려면 다음과 같이 changeState() 메서드를 구현해 주면 될 것 같다.

public class Job {

    public static enum State {
        MEDIASOURCECOPYING, COMPLETED
    }

    private State state;

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    public State getLastState() {
        return state;
    }

    public void changeState(State newState) {
        this.state = newState;
    }

}

이제 테스트를 실행해 보자. 오, 예! 녹색바다.

실패의 의미가 없다.

미디어 원본 파일을 복사하는 것 뿐만 아니라 변환 처리, 썸네일 추출, 결과 전송, 통지에 대해서도 동일하게 실패 과정에 대한 테스트를 넣으려고 했으나, 마음에 안 드는 부분을 발견했다. transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 메서드의 Job에 대한 검증 부분을 다시 살펴보자.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        Job job = jobRepository.findById(jobId);
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        ...
    }

job.isSuccess()는 작업의 성공 여부는 알려주는데, 작업이 중간에 실패했는지 아니면 작업이 진행중인지의 여부는 알려주지 못한다. 그렇다, 우리에겐 다음이 필요하다.
  • 작업이 시작 전 / 진행 중 / 끝남 여부
  • 작업의 성공/실패 여부
    • 실패했다면, 어느 단계에서 실패했는 지 그리고 실패 원인
몇 가지 assert를 떠올려 봤는데, 아래와 같이 하면 좋을 것 같다.

    @Test
    public void transcodeSuccessfully() {
        ...
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());
        transcodingService.transcode(jobId);

        job = jobRepository.findById(jobId);
        assertTrue(job.isFinished());
        assertTrue(job.isSuccess());
        assertEquals(Job.State.COMPLETED, job.getLastState());
        assertNull(job.getOccurredException());
        ... // verify 들
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isFinished());
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        assertNotNull(job.getOccurredException());
        ... // verify 들
    }

추가된 메서드는 다음과 같다.
  • Job.isWaiting(): 작업이 대기 중인지의 여부
  • Job.isFinished(): 작업이 완료되었는지의 여부
  • Job.getOccuredException(): 진행 과정 중 발생한 익셉션을 구한다.
Job.isSuccess()는 작업이 성공/실패 여부를 의미하도록 변경했다. 컴파일 에러가 발생하므로, 각각의 메서드를 추가해 주고 점진적으로 테스트를 통과시켜 나갈 차례이다. transcodeSuccessfully() 테스트가 통과될 때 까지 테스트 실행/Job 클래스 수정을 반복해서 Job 클래스를 다음과 같이 수정하였다.

public class Job {

    public static enum State {
        MEDIASOURCECOPYING, COMPLETED
    }

    private State state;
    private Exception occurredException;

    public void changeState(State newState) {
        this.state = newState;
    }

    public boolean isWaiting() {
        return state == null;
    }

    public boolean isFinished() {
        return isSuccess() || isExceptionOccurred();
    }

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    private boolean isExceptionOccurred() {
        return occurredException != null;
    }

    public State getLastState() {
        return state;
    }

    public Exception getOccurredException() {
        return occurredException;
    }

}

이제 남은 작업은 transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 테스트를 통과시킬 차례이다. 실패 케이스의 테스트를 실행해보면 아래 코드를 통과하지 못한다.

assertTrue(job.isFinished()); // 통과 실패
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
assertNotNull(job.getOccurredException());

job.isFinished() 메서드는 작업이 성공했거나 익셉션이 발생한 경우 true를 리턴하도록 구현하였다. 따라서, 필요한 것은 TranscodingServiceImpl이 처리 도중 익셉션이 발생하면 그 사실을 Job 객체에 알려주는 것이다. 뭔가 Job 객체에게 알려주는 기능이 필요해졌다. 음,, 지금까지 글을 따라 읽어왔다면 이 쯤에서 새로운 협업 객체가 등장할 것 같은 느낌을 받을 것이다.

익셉션 발생을 Job에 알리기 위한 새로운 협업 객체의 등장

각 처리 과정에서 익셉션이 발생하면 그 사실을 Job에게 알려주는 기능을 추가하자. 약간의 고민을 한 끝에 아래와 같이 해 보기로 결정했다.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        ...
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        try {
            return mediaSourceCopier.copy(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }
    ... // 나머지 메서드도 동일하게 transcodingExceptionHandler 사용 코드 추가

transcodingExceptionHandler라는 새로운 협업 객체가 등장했다. 이 객체의 notifyToJob() 메서드는 Job 객체에게 익셉션이 발생했다는 사실을 알린다. 그리고 익셉션이 사라지지 않도록 재전파한다.

transcodingExceptionHandler 협업 객체가 없으므로, 필드로 정의하고 transcodingExceptionHandler 객체를 위한 인터페이스를 생성하고, 그 인터페이스에 notifyToJob() 메서드를 정의하자. 물론, 생성자를 통해서 추가된 협업 객체를 전달받도록 수정하는 것도 잊지 말자.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    private JobStateChanger jobStateChanger;
    private TranscodingExceptionHandler transcodingExceptionHandler;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier,
            JobStateChanger jobStateChanger,
            TranscodingExceptionHandler transcodingExceptionHandler) {
        ...
        this.transcodingExceptionHandler = transcodingExceptionHandler;
    }

    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        ...
        changeJobState(jobId, Job.State.COMPLETED);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        try {
            return mediaSourceCopier.copy(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

TranscodingServiceImpl의 생성자를 변경했으므로, 테스트 코드도 함께 수정해 준다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private TranscodingExceptionHandler transcodingExceptionHandler;

    private Job mockJob = new Job();

    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);
        ...
    }


앞서 실패했던 부분을 다시 보자.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isFinished()); // 통과 실패
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        assertNotNull(job.getOccurredException());
...

    }

이제 남은 작업은 새로 추가된 협업 객체를 이용해서 위 테스트과 통과되도록 만드는 것이다. 우선, 테스트 클래스에 새롭게 추가된 Mock 객체인 transcodingExceptionHandler의 notifyToJob() 메서드가 호출되면 mockJob객체에 발생한 익셉션을 전달할 수 있도록 exceptionOccured() 메서드를 호출한다.

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);

        ...

        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                RuntimeException ex = (RuntimeException) invocation.getArguments()[1];
                mockJob.exceptionOccurred(ex);
                return null;
            }
        }).when(transcodingExceptionHandler).notifyToJob(anyLong(),
                any(RuntimeException.class));
    }

물론, 아직 Job 클래스에 exceptionOccurred() 메서드는 없다. 이제부터 만들어야 한다. 이 메서드를 만드는 건 어렵지 않을 것 같다. 다음과 같이 이 메서드를 통해서 전달받은 익셉션 객체를 필드에 보관하기만 하면 된다.

package org.chimi.s4t.domain.job;

public class Job {
    ...
    public boolean isFinished() {
        return isSuccess() || isExceptionOccurred();
    }

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    private boolean isExceptionOccurred() {
        return occurredException != null;
    }

    public Exception getOccurredException() {
        return occurredException;
    }

    public void exceptionOccurred(RuntimeException ex) {
        occurredException = ex;
    }

}

필요한 구현을 한 것 같으므로 테스트를 실행해 보자. 야호! 녹색바다!

이제 나머지 실패 시나리오에 대해서 추가적으로 테스트 클래스를 만들고 비슷한 과정을 반복하면 된다. 아, 잠깐! 비슷한 과정을 반복하다보면 각각의 테스트 메서드에 중복된 코드가 출현하게 된다. 중복은 악의 축이므로, 이를 없애야 한다. 테스트 코드도 유지보수가 가능하도록 리팩토링을 해 주어야 하는데, 이 과정은 연습 3에서 진행해 본다.


결과는?

  • 드디어 Job과 JobRepository가 출현했다.
  • 두 개의 협업 객체가 새롭게 추가되었다. (최초에 필자가 머리속으로 상상했던 것 보다 더 많은 협업 객체가 출현했다.)
  • 성공 시나리오와 실패 시나리오에 대한 테스트도 만들어졌다.

연습3에서는 앞서 말한데로 테스트 코드의 중복을 제거해 보도록 하자.

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.25 07:16 신고  댓글주소  수정/삭제  댓글쓰기

    . service, repository, controller 등에는 위임(상호작용)을 제외하고는 테스트가 필요할 수준의 코드가 없는 것이 좋다고 생각함. 그에 비해 위 테스트는 너무 자세함. exception이 발생해야 할 경우 발생했나만 확인하면 되지 굳이 catch로 막고, assert/verify 등을 할 필요가 있을까 ?
    . TranscodingService의 생성자에 인자의 수가 너무 많은 듯(6개). 이럴 경우 setter가 더 보기 좋을 수도. 그리고 이렇게 많아야만 하는지가 가장 의문시 됨.
    . Job#changeState에서 state를 바로 변경하기 보다 start, end 등의 trigger 메소드를 추가하고 해당 메소드에서 state 객체에게 위임(this를 넘겨서 state가 자신을 변경하고 job의 상태도 변경하도록)하는 것이 좋을 듯.

    • 최범균 madvirus 2012.10.25 09:24 신고  댓글주소  수정/삭제

      TranscodingService와 Job의 State는 다음과 같은 고민의 결과입니다. 아직까지 완전한 답을 찾지 못했고, 그래서 일단 제가 하기 쉬운 방법으로 구현을 진행하게 되었어요.

      첫 번째 고민거리는 Job에 execute() 메서드를 넣고, 그 execute() 메서드에서 변환 처리를 수행하는 것에 대한 부분이였어요. Job이 작업을 처리하면, TranscodingService는 리포지토리에서 Job을 구하고, 그 Job의 execute() 메서드를 호출하는 걸로 끝나게 되죠.

      처음에는 이 방법으로 약간의 구현을 생각해봤는데, 다음과 같은 것들이 걸렸어요.
      - 각 단계가 끝나면 Job의 상태가 변경되어서 작업 요청자가 확인할 수 있어야 함
      - State 패턴을 적용해서 State별로 자기 작업을 수행하고, 실패를 처리하는 등의 것을 생각해 봄
      - 그런데, 향후에 뭐가 되었든 Job이 Persistence 영역과 엮이게 되면, 각 State 객체의 메서드가 내부적으로 트랜잭션을 처리해 주어야 하고, State의 전이가 발생하면 Job의 물리적 데이터가 해당 State로 변경되어야 함

      그런데, 제가 좋아하는 ORM과 스프링을 사용할 경우, (사실, 이게 문제죠! 제가 구현을 너무 빨리 고려했다는 것),
      - Job은 DB 세션과 연결되어 있어서 State의 전이가 일어날 때 그걸 바로 반영하려면 뭔가 Persistece 관련 기능에 의존할 것 같고
      - State의 메서드마다 별도로 트랜잭션을 처리하려면 뭔가 Transaction을 추상화해서 Inject 해 줘야 할 것 같고,
      - State의 전이는 새로운 State 객체의 생성과도 연결되니 객체의 생성 시점에 Inject 하는 방법이 필요할 것 같고

      음,, 이렇게 생각이 뻗어나가면서 해답을 찾지 못했어요. 이건 좋은 아이디어 있으면 좀 주셔요.

      하튼, 그래서 트랜잭션을 쉽게 분리할 수 있는 방법을 찾았고, 그 결과로 각 단계마다 별도 협업 객체를 출현시키는 방법이 출현하게 되었어요.

      이렇게 되다보니 Job의 상태가 단순 값으로 바뀌었고, 상태 전이 부분은 향후 리팩을 기약하며(^^;) 먼저 동작하는 코드를 만들었어요.

      시간이 많으면 더 고민하겠는데, 저녁에 짬짬히 하다보니 (드라마를 병행하면서....) 생각이 잘 안 떠오르네요.

* TDD 과정을 공유하기 위해 연습하는 과정을 글로 남긴다.


시작은 간단한 설계로


만들어 볼 소프트웨어는 동영상 파일 변환 시스템이다. 주요 기능을 다음과 같이 잡아 보았다.

  • 임의 위치의 미디어 파일을 읽어와 지정한 포맷으로 변환한 뒤 결과를 원하는 위치에 저장한다.
  • 원하는 방식으로 썸네일 이미지를 생성해서 원하는 위치에 저장한다.
  • 진행 상태를 중간 중간 확인할 수 있다.
  • 변환 결과를 통지 받는다.
보다 자세한 기능 명세가 필요하겠지만, 개요는 위와 같다. 변환 요청은 REST로 받을 수도 있고, 일반 웹 폼으로 받을 수도 있고, 소켓으로 받을 수도 있겠지만, UI는 핵심이 아니므로 쉽게 UI를 붙일 수 있는 구조로만 가면 될 뿐 최초 개발에 UI를 먼저 개발하진 않는다.

아주 간단하게 변환 처리와 관련된 부분의 설계를 먼저 해 보았다. 


최초 설계


변환 처리를 수행할 TranscodingService가 필요할 것 같았고, 그 외에 작업의 의미를 담는 Job 그리고 그 Job을 보관할 JobRepository가 필요하다고 판단했다. 


테스트 코드로 시작하기


자, 이제 할 작업은 TranscodingService를 위한 테스트를 만드는 것이었다.


가장 먼저 만든 코드는 다음과 같다.


public class TranscodingServiceTest {


    @Test

    public void transcodeSuccessfully() {

        // 미디어 원본으로부터 파일을 로컬에 복사한다.

        // 로컬에 복사된 파일을 변환처리한다.

        // 로컬에 복사된 파일로부터 이미지를 추출한다.

        // 변환된 결과 파일과 썸네일 이미지를 목적지에 저장

        // 결과를 통지

    }

}


transcodeSuccessfully() 메서드는 TranscodingService가 성공적으로 동작하는 상황을 테스트 하기 위해 만들었으며, 성공적으로 작업이 진행될 때 수행해야 할 작업을 주석으로 입력해 놓았다. 이 주석을 구현으로 바꿔치기 해 나가면서 TranscodingService를 점진적으로 완성해 나갈 것이다.


가장 먼저 해야 할 작업은 미디어 원본으로부터 파일을 로컬에 복사해 오는 부분을 구현하는 것이다. 자, 시작은 아래와 같다.


public class TranscodingServiceTest {


    @Test

    public void transcodeSuccessfully() {

        // 미디어 원본으로부터 파일을 로컬에 복사한다.

        Long jobId = new Long(1);

        File multimediaFile = copyMultimediaSourceToLocal(jobId);


        // 로컬에 복사된 파일을 변환처리한다.

        // 로컬에 복사된 파일로부터 이미지를 추출한다.

        // 변환된 결과 파일과 썸네일 이미지를 목적지에 저장

        // 결과를 통지

    }


    private File copyMultimediaSourceToLocal(Long jobId) {

        return mediaSourceCopier.copy(jobId); // 컴파일 에러!

    }

}


미디어 원본 파일을 로컬에 복사해주는 부분을 copyMultimediaSourceToLocal() 메서드가 처리하도록 했고, 이 메서드는 다시 mediaSourceCopier 라는 객체의 copy() 메서드에 위임하도록 코드를 작성했다. 아직 mediaSourceCopier의 정확한 구현은 모르지만, 단지 jobId에 해당하는 Job과 관련된 미디어 원본 파일을 로컬 파일 시스템에 복사한 뒤에 복사된 그 파일을 리턴해주는 역할을 mediaSourceCopier 객체에 부여했다. 아! 아직 Job 클래스는 출현하지 않았다.


컴파일 오류 때문에 위 테스트를 수행할 수 없다. 테스트를 통과시키기 위해 Mocking을 할 것이다. 우선, mediaSourceCopier 필드를 추가한다. (이클립스라면 빨간줄에서 Ctrl+1을 눌러서 빠르게 필드를 추가할 수 있다.) 그리고, 그 필드의 타입으로 MediaSourceCopier를 입력한다.


public class TranscodingServiceTest {


    private MediaSourceCopier mediaSourceCopier; // MediaSourceCopier 타입 없음


    @Test

    public void transcodeSuccessfully() {

        // 미디어 원본으로부터 파일을 로컬에 복사한다.

        Long jobId = new Long(1);

        File multimediaFile = copyMultimediaSourceToLocal(jobId);


        // 로컬에 복사된 파일을 변환처리한다.

        // 로컬에 복사된 파일로부터 이미지를 추출한다.

        // 변환된 결과 파일과 썸네일 이미지를 목적지에 저장

        // 결과를 통지

    }


    private File copyMultimediaSourceToLocal(Long jobId) {

        return mediaSourceCopier.copy(jobId); // copy 메서드 없음

    }

}


위 코드가 마저 컴파일 되도록 MediaSourceCopier 인터페이스를 추가하고, 그 인터페이스 copy() 메서드를 만들어 넣자.

public interface MediaSourceCopier {

    File copy(Long jobId);

}

이제 TranscodingServiceTest 클래스가 정상적으로 컴파일 된다. 단위 테스트를 실행해보자. 이런! NullPointerException이다. TranscodingServiceTest의 mediaSourceCopier 필드에 어떤 객체도 할당하지 않았기 때문에 Null 뻑이 났다.

아직 TranscodingService를 만들지도 않았는데, MediaSourceCopier의 구현체를 먼저 만들고 싶진 않다. 지금은 TranscodingService 구현을 만드는데 집중해야 한다. 그래서 MediaSourceCopier는 Mock 객체로 대체해서 위 테스트를 통과시킬 것이다. 필자가 좋아하는 Mock 라이브러리는 Mockito이므로 이를 이용해서 아래와 같이 Mock 객체를 생성했다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceTest {

    @Mock
    private MediaSourceCopier mediaSourceCopier;

    @Test
    public void transcodeSuccessfully() {
        Long jobId = new Long(1);
        File mockMultimediaFile = mock(File.class);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
        // 미디어 원본으로부터 파일을 로컬에 복사한다.
        File multimediaFile = copyMultimediaSourceToLocal(jobId);

        // 로컬에 복사된 파일을 변환처리한다.
        // 로컬에 복사된 파일로부터 이미지를 추출한다.
        // 변환된 결과 파일과 썸네일 이미지를 목적지에 저장
        // 결과를 통지
        verify(mediaSourceCopier, only()).copy(jobId);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        return mediaSourceCopier.copy(jobId);
    }
}

이제 테스트 코드를 실행해 보자. 녹색바! 통과다. 통과되었으므로, 불필요한 주석은 제거한다. (위 코드에서 취소 선을 그은 주석)

위와 비슷하게 로컬에 복사된 파일을 변환처리하기 위한 테스트 코드를 아래와 같이 추가하였다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceTest {

    @Mock
    private MediaSourceCopier mediaSourceCopier;
    @Mock
    private Transcoder transcoder;

    @Test
    public void transcodeSuccessfully() {
        Long jobId = new Long(1);
        File mockMultimediaFile = mock(File.class);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        List<File> mockMultimediaFiles = new ArrayList<File>();
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
                mockMultimediaFiles);
        
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        // 로컬에 복사된 파일을 변환처리한다.
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        // 로컬에 복사된 파일로부터 이미지를 추출한다.
        // 변환된 결과 파일과 썸네일 이미지를 목적지에 저장
        // 결과를 통지
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        return mediaSourceCopier.copy(jobId);
    }

    private List<File> transcode(File multimediaFile, Long jobId) {
        return transcoder.transcode(multimediaFile, jobId);
    }
}

Transcoder 인터페이스도 알맞게 작성해 준다. 위 코드를 테스트하면 녹색바가 나올 것이다.

이런 과정을 반복적으로 거치면 최종적으로 아래와 같은 테스트 코드가 만들어진다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceTest {

    @Mock
    private MediaSourceCopier mediaSourceCopier;
    @Mock
    private Transcoder transcoder;
    @Mock
    private ThumbnailExtractor thumbnailExtractor;
    @Mock
    private CreatedFileSender createdFileSender;
    @Mock
    private JobResultNotifier jobResultNotifier;
    
    @Test
    public void transcodeSuccessfully() {
        Long jobId = new Long(1);
        File mockMultimediaFile = mock(File.class);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        List<File> mockMultimediaFiles = new ArrayList<File>();
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
                mockMultimediaFiles);

        List<File> mockThumbnails = new ArrayList<File>();
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
                mockThumbnails);
                
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThubmail(multimediaFile, jobId);
        sendCreatedFilesToDestination(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);
        
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).send(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        return mediaSourceCopier.copy(jobId);
    }

    private List<File> transcode(File multimediaFile, Long jobId) {
        return transcoder.transcode(multimediaFile, jobId);
    }    

    private List<File> extractThubmail(File multimediaFile, Long jobId) {
        return thumbnailExtractor.extract(multimediaFile, jobId);
    }

    private void sendCreatedFilesToDestination(List<File> multimediaFiles,
            List<File> thumbnails, Long jobId) {
        createdFileSender.send(multimediaFiles, thumbnails, jobId);
    }

    private void notifyJobResultToRequester(Long jobId) {
        jobResultNotifier.notifyToRequester(jobId);
    }
}


테스트 코드로부터 TranscodingService 클래스 도출하기

TranscodingService 클래스와 관련된 거의 모든 코드가 출현했다. 이제 남은 작업은 TranscodingService를 도출하는 것이다. 앞서 코드에서 굵은 색으로 표시한 부분을 transcode()라는 이름을 갖는 메서드로 분리해보자. 그러면, 아래와 같이 테스트 코드가 바뀐다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceTest {

    ... // Mock 객체들

    @Test
    public void transcodeSuccessfully() {
        Long jobId = new Long(1);
        File mockMultimediaFile = mock(File.class);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        List<File> mockMultimediaFiles = new ArrayList<File>();
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
                mockMultimediaFiles);

        List<File> mockThumbnails = new ArrayList<File>();
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
                mockThumbnails);
                
        transcode(jobId);
        
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).send(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }
    
    private void transcode(Long jobId) {
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThubmail(multimediaFile, jobId);
        sendCreatedFilesToDestination(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);    
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        return mediaSourceCopier.copy(jobId);
    }
    ... // 나머지 메서드
}

코드를 바꿨으니 테스트 코드를 실행해서 녹색바가 보이는지 확인한다.

이제 조금만 더 하면 된다. 대망의 TranscodingService 클래스를 만들 차례이다. 다음과 같은 작업을 하면 된다.
  1. TranscodingService 클래스를 만든다.
  2. transcode() 메서드 및 그 메서드에서 사용하는 메서드를 TranscodingService 클래스로 옮긴다.
  3. transcode() 메서드에서 필요로 하는 협업 객체를 필드로 정의한다.
  4. 생성자를 이용해서 또는 Setter를 이용해서 협업 객체를 초기화한다.

결과로 만들어진 TranscodingService 클래스는 아래와 같다.


public class TranscodingService {

    private MediaSourceCopier mediaSourceCopier;

    private Transcoder transcoder;

    private ThumbnailExtractor thumbnailExtractor;

    private CreatedFileSender createdFileSender;

    private JobResultNotifier jobResultNotifier;


    public TranscodingService(MediaSourceCopier mediaSourceCopier,

            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,

            CreatedFileSender createdFileSender,

            JobResultNotifier jobResultNotifier) {

        this.mediaSourceCopier = mediaSourceCopier;

        this.transcoder = transcoder;

        this.thumbnailExtractor = thumbnailExtractor;

        this.createdFileSender = createdFileSender;

        this.jobResultNotifier = jobResultNotifier;

    }


    public void transcode(Long jobId) {

        File multimediaFile = copyMultimediaSourceToLocal(jobId);

        List<File> multimediaFiles = transcode(multimediaFile, jobId);

        List<File> thumbnails = extractThubmail(multimediaFile, jobId);

        sendCreatedFilesToDestination(multimediaFiles, thumbnails, jobId);

        notifyJobResultToRequester(jobId);

    }


    private File copyMultimediaSourceToLocal(Long jobId) {

        return mediaSourceCopier.copy(jobId);

    }


    private List<File> transcode(File multimediaFile, Long jobId) {

        return transcoder.transcode(multimediaFile, jobId);

    }


    private List<File> extractThubmail(File multimediaFile, Long jobId) {

        return thumbnailExtractor.extract(multimediaFile, jobId);

    }


    private void sendCreatedFilesToDestination(List<File> multimediaFiles,

            List<File> thumbnails, Long jobId) {

        createdFileSender.send(multimediaFiles, thumbnails, jobId);

    }


    private void notifyJobResultToRequester(Long jobId) {

        jobResultNotifier.notifyToRequester(jobId);

    }


}


이제 다음으로 할 작업은 테스트 클래스가 TranscodingService 클래스를 사용하도록 변경할 차례이다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {


    private Long jobId = new Long(1);


    @Mock

    private MediaSourceCopier mediaSourceCopier;

    @Mock

    private Transcoder transcoder;

    @Mock

    private ThumbnailExtractor thumbnailExtractor;

    @Mock

    private CreatedFileSender createdFileSender;

    @Mock

    private JobResultNotifier jobResultNotifier;


    private TranscodingService transcodingService;


    @Before

    public void setup() {

        transcodingService = new TranscodingService(mediaSourceCopier,

                transcoder, thumbnailExtractor, createdFileSender,

                jobResultNotifier);

    }


    @Test

    public void transcodeSuccessfully() {

        File mockMultimediaFile = mock(File.class);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);


        List<File> mockMultimediaFiles = new ArrayList<File>();

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);


        List<File> mockThumbnails = new ArrayList<File>();

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);


        transcodingService.transcode(jobId);


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);

        verify(createdFileSender, only()).send(mockMultimediaFiles,

                mockThumbnails, jobId);

        verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }


}


뭔가 바꿨으니 테스트를 실행해 보자. 녹색! 오케이!


이제 진짜 하나 남았다. TranscodingService를 TranscodingServiceImpl 클래스로 바꾸고, TranscodingService 인터페이스를 만들자. 그리고, TranscodingServceImpl 클래스가 TranscodingService 인터페이스를 상속받도록 하자. 만들어진 TranscodingService 인터페이스는 다음과 같다.


public interface TranscodingService {


    public void transcode(Long jobId);


}


기존의 TranscodingService 클래스는 이름을 TranscodingServiceImpl로 변경하고 TranscodingService 인터페이스를 상속받도록 구현한다.


public class TranscodingServiceImpl implements TranscodingService {

    private MediaSourceCopier mediaSourceCopier;

    private Transcoder transcoder;

    private ThumbnailExtractor thumbnailExtractor;

    private CreatedFileSender createdFileSender;

    private JobResultNotifier jobResultNotifier;


    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,

            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,

            CreatedFileSender createdFileSender,

            JobResultNotifier jobResultNotifier) {

        ...

    }


    @Override

    public void transcode(Long jobId) {

        ...

    }

    ...

}


테스트 코드는 이제 TranscodingServceImpl 클래스를 사용하도록 변경하면 끝!


결과는?


결과는 다음과 같다.

  1. 테스트로부터 시작해서 TranscodingService 객체가 협업해야 하는 다른 객체들의 인터페이스와 기능을 점진적으로 식별했다.
  2. 아직 정상적으로 실행되는 경우에 대해서만 TranscodingService가 구현되어 있다. 중간에 일부가 실패하는 경우에 대한 TranscodingService 구현이 필요하다. 이걸 단위 테스트로 한 번 만들어 볼 것이다.
  3. 최초에 설계했던 Job 클래스나 JobRepository 등은 아직 출현하지 않았다. 이 타입들은 앞으로 구현을 진행하면서 점진적으로 출현할 것 같다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.24 10:45 신고  댓글주소  수정/삭제  댓글쓰기

    오호 ! Good job !!
    그리고 다음과 같은 생각도 듦.
    이런 테스트는 controller, service, repository에 적합할 듯. 근데 이 놈들에게는 로직이 거의 없어서 테스트의 필요가 적은 것이 좋을 듯.
    infra(http, html, jdbc 등)에 의존성이 없는 POJO에 중요한 로직을 위치시키고 이에 대한 테스트를 heavy하게 하는 것이 좋은 방법일 듯.

    • 최범균 madvirus 2012.10.24 11:14 신고  댓글주소  수정/삭제

      저도 공감합니다.
      그리고, 이건 연습 2부에서 실패 상황이 출현하면서 조금 더 테스트가 쓸모 있어 질 예정이에요. (아주 약간의 로직이 들어갈 것도 같구요.)

필자는 원하는 기능을 제공하는 Mock 라이브러리를 찾지 못해 (또는 완전히 학습하지 못해?) 필요한 기능을 제공하는 간단한 라이브러리를 직접 만들어서 사용한 적도 있다. 그런데, 매우 맘에 드는 Mock 라이브러리를 하나 찾게 되었고, 몇 달 정도 사용해 본 결과 이전의 몇몇 Mock 라이브러리도 사용성 면에서 편리하였기에 본 글에서 사용방법을 소개하고자 한다. 그 Mock 라이브러리는 바로 Mockito 라는 것이다.

Mockito는 http://code.google.com/p/mockito/ 사이트에서 mockito-all-1.6.jar 파일을 다운로드 받을 수 있다. Maven을 사용한다면 아래의 의존을 pom.xml 파일에 추가해주면 된다.

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-all</artifactId>
    <version>1.6</version>
    <scope>test</scope>
</dependency>

Mock 객체를 만드는 이유가 크게 1) 협업하는 클래스의 완성 여부에 상관없이 내가 만든 클래스를 테스트 할 수 있고 2) 내가 만든 클래스가 연관된 클래스와 올바르게 협업하는 지 확인할 수 있기 때문이므로 이 두 가지 상황을 연출하기 위해 다음과 같은 클래스에 대한 테스트를 만들어 봄으로써 Mockito의 사용법을 설명해 나가도록 하겠다.

public class WriteArticleServiceImpl {
    private IdGenerator idGenerator;
    private ArticleDao articleDao;

    public Article writeArticle(Article article) {
        Integer id = idGenerator.getNextId();
        article.setId(id);
        articleDao.insert(article);
        return article;
    }
   
    // idGenerator와 articleDao에 대한 setter
    ...
}

위 코드에서 WriteArticleServiceImpl 클래스를 테스트 하려면 IdGenerator 인터페이스와 ArticleDao 인터페이스의 구현 객체를 필요로 한다. 하지만, 아직 IdGenerator와 ArticleDao를 구현한 클래스가 완전히 구현되지 않았다. 또한, 테스트 DB의 구축도 완전하지 않은 상황이라고 하자. 따라서 현재로서는 통합 테스트는 진행할 수 없고 WriteArticleServceImpl에 대한 단위 테스트만 진행할 수 있다.

Mockito를 이용한 Mock 객체 생성

WriteArticleServiceImpl 클래스를 테스트하는 코드는 다음과 같을 것이다.

    @Test
    public void writeArticle() {
        WriteArticleServiceImpl writeArticleService = new WriteArticleServiceImpl();
        Article article = new Article();
        Article writtenArticle = writeArticleService.writeArticle(article);
       
        assertNotNull(writtenArticle);
        assertNotNull(writtenArticle.getId());
    }

하지만 위 테스트를 실행하면 NullPointerException이 발생하는 데, 그 이유는 WriteArticleServiceImpl.writeArticle() 메서드에서 IdGenerator와 ArticleDao을 구현한 객체를 사용하기 때문이다. 따라서, WriteArticleServiceImpl 클래스를 테스트 하려면 IdGenerator와 ArticleDao를 가짜로 구현한 Mock 객체를 전달해 주어야 한다.

Mockito를 이용할 경우 이는 다음과 같이 Mockito.mock() 이라는 메서드를 이용해서 생성할 수 있다. 아래는 Mock 객체 생성 예이다.

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

import org.junit.Test;

public class WriteArticleServiceImplTest {

    @Test
    public void writeArticle() {
        // mock 객체 생성
        ArticleDao mockedDao = mock(ArticleDao.class);
        IdGenerator mockedGenerator = mock(IdGenerator.class);
       
        WriteArticleServiceImpl writeArticleService = new WriteArticleServiceImpl();
        writeArticleService.setArticleDao(mockedDao);
        writeArticleService.setIdGenerator(mockedGenerator);
       
        Article article = new Article();
        Article writtenArticle = writeArticleService.writeArticle(article);
       
        assertNotNull(writtenArticle);
    }
}


Mock 객체의 메서드 호출 검증하기

Mock 객체를 사용하는 이유는 테스트 하려는 클래스가 연관된 객체와 올바르게 협업하는 지를 테스트 하기 위함도 있다. 따라서, Mock 객체의 메서드가 올바르게 실행되는 지 확인해볼 필요가 있다. Mock 객체의 특정 메서드가 호출되었는 지 확인하려면 Mockito.verify() 메서드와 Mock 객체의 메서드를 함께 사용하면 된다. 아래는 사용 예이다.

    @Test
    public void writeArticle() {
        // mock 객체 생성
        ArticleDao mockedDao = mock(ArticleDao.class);
        IdGenerator mockedGenerator = mock(IdGenerator.class);
       
        WriteArticleServiceImpl writeArticleService = new WriteArticleServiceImpl();
        writeArticleService.setArticleDao(mockedDao);
        writeArticleService.setIdGenerator(mockedGenerator);
       
        Article article = new Article();
        Article writtenArticle = writeArticleService.writeArticle(article);
       
        assertNotNull(writtenArticle);
        verify(mockedGenerator).getNextId();
        verify(mockedDao).insert(article);
    }


위 코드에서 verify(mockedGenerator).getNextId() 메서드는 mockedGenerator 객체의 getNextId() 메서드가 호출되었는 지의 여부를 확인한다. verify(mockedDao).insert(article) 메서드의 경우 mockedDao 객체의 insert() 메서드 호출 중에서 article 객체를 인자로 전달받는 호출이 있었는 지 여부를 확인한다.

일단 Mock 객체가 만들어지면 해당 Mock 객체는 메서드 호출을 모두 기억하기 때문에, 어떤 메서드 호출이든 검증할 수 있다.

원하는 값을 리턴하는 스텁 만들기

앞서 테스트에서 다음과 같이 writeArticleService.writeArticle(aritlce)이 리턴한 객체가 알맞은 ID 값을 갖는 지 확인하는 검증 코드를 넣었다고 하자.

Article writtenArticle = writeArticleService.writeArticle(article);
assertNotNull(writtenArticle);
assertNotNull(writtenArticle.getId()); // 에러 발생

위 코드에서 assertNotNull(writtenArticle.getId()) 메서드는 검증에 실패한다. 그 이유는 WriteArticleServiceImpl.writeArticle() 메서드가 IdGenerator.nextId() 메서드를 이용해서 ID 값을 가져온 뒤 aritlcle 객체에 저장하기 때문이다. (아래 코드 참조)

    public Article writeArticle(Article article) {
        Integer id = idGenerator.getNextId();
        article.setId(id);
        articleDao.insert(article);
        return article;
    }

테스트 코드에서는 IdGenerator로 Mock 객체를 전달했는데, Mockito.mock()을 이용해서 생성한 객체의 메서드는 리턴 타입이 객체인 경우 null을 리턴하고 기본 데이터 타입인 경우 기본 값을 리턴한다. 따라서, 리턴 타입이 Integer인 (즉, 객체인) IdGenerator.getNextId()에 대해서는 null을 리턴하고 따라서 assertNotNull(writtenArticle.getId()) 코드에서 writtenArticle.getId() 메서드가 null을 리턴하게 되어 검증에 실패하는 것이다.

Mockito는 Mock 객체의 메서드가 알맞은 값을 리턴하는 스텁을 만들 수 있는 기능을 제공하고 있다. 이 메서드는 when - then의 형식을 띄고 있는데, 아래 코드는 실제 사용 예를 보여주고 있다.

...
IdGenerator mockedGenerator = mock(IdGenerator.class);
when(mockedGenerator.getNextId()).thenReturn(new Integer(1));

WriteArticleServiceImpl writeArticleService = new WriteArticleServiceImpl();
writeArticleService.setIdGenerator(mockedGenerator);

Article article = new Article();
Article writtenArticle = writeArticleService.writeArticle(article);

assertNotNull(writtenArticle);
assertNotNull(writtenArticle.getId());
verify(mockedGenerator).getNextId();

Mockito.when() 메서드는 메서드 호출 조건을 그리고 thenReturn()은 그 조건을 충족할 때 리턴할 값을 지정한다. 위 코드의 경우 mockedGenerator.getNextId() 메서드가 호출되면 Integer(1)을 리턴하라는 의미를 갖는다.

Mock 객체의 메서드 호출 시 전달되는 인자 값에 따라서 리턴 값을 다르게 지정할 수도 있다. 아래 코드는 예를 보여주고 있다.

Article article = new Article();
when(mockedArticleDao.insert(article)).thenReturn(article));


Argument Matcher를 이용한 인자 매칭

보통, when()으로 스텁을 생성하거나 verify()로 메서드 호출 여부를 확인할 때는 특졍한 값을 지정한다.

// 인자 값이 1인 경우 스텁 생성
when(mockedListService.getArticles(1)).thenReturn(someList);
// 인자 값으로 1을 전달하여 getArticles() 메서드를 호출했는 지의 여부
verify(mockedListService).getArticles(1);

하지만, 특정한 값이 아닌 임의의 값에 대해서 when() 메서드와 verify() 메서드를 실행하고 싶을 때가 있다. 이런 경우에는 Argument Matcher를 이용해서 인자 값을 지정하면 된다. 예를 들어, 임의의 정수 값을 인자로 전달받은 메서드 호출을 when()과 verify()에서 표현하고 싶다면 다음과 같이 Matchers.anyInt() 메서드를 사용하면 된다.

when(mockedListService.getArticles(anyInt())).thenReturn(someList);
...
verify(mockedListService).getArticles(anyInt());

Matchers 클래스는 anyInt() 뿐만 아니라 anyString(), anyDouble(), anyLong(), anyList(), anyMap() 등의 메서드를 제공하는데, 이들 메서드에 대한 자세한 내용은 http://mockito.googlecode.com/svn/branches/1.6/javadoc/org/mockito/Matchers.html 사이트를 참고하기 바란다.

인자 중 한가지라도 Argument Matcher를 사용하면 나머지 인자에 대해서도 Matcher를 사용해야 한다. 예를 들어, 아래 코드는 예외를 발생한다.

Authenticator authenticator = mock(Authenticator.class);
when(authenticator.authenticate(anyString(), "password")).thenReturn(authObj);

만약 여러 인자 중 특정 값을 명시해야 하는 경우가 필요하다면 eq() Matcher를 사용하면 된다. 아래는 위 코드를 eq()를 이용해서 수정한 코드를 보여주고 있다.

Authenticator authenticator = mock(Authenticator.class);
when(authenticator.authenticate(anyString(), eq("password"))).thenReturn(authObj);

Mockito 클래스는 Matchers 클래스를 상속받고 있기 때문에 Mockito 클래스의 static 메서드를 static import 하면 Matchers 클래스에 정의된 메서드를 사용할 수 있다.

thenThrow()를 이용한 예외 발생

Mock 객체의 메서드 호출시 예외를 발생시키고 싶을 때가 있는데, 이런 경우에는 thenThrow() 메서드를 사용하면 된다. 아래는 사용 예를 보여주고 있다.

when(mockedDao.insert(article)).thenThrow(new RuntimeException("invalid title"));

thenThrow() 메서드에서 발생시킬 예외 객체를 전달해주면, when()에서 지정한 조건의 메서드가 호출될 때 예외를 발생시킨다.

메서드 호출 회수 검사

메서드가 지정한 회수 만큼 호출되었는 지의 여부를 확인하려면 times() 메서드를 사용하면 된다. 예를 들어, Mock 객체의 특정 메서드가 3번 호출되었는 지 확인하려면 다음과 같이 verify() 메서드의 두 번째 인자에 times() 메서드를 (정확히는 times() 메서드의 리턴 값을) 전달해주면 된다.

verify(mockedAuthenticator, times(3)).authenticate(anyString(), anyString());

호출 회수를 따로 지정하지 않을 경우 times(1)이 기본 값이 된다. times() 외에 다음과 같은 메서드를 사용할 수 있다.

  • times(int) - 지정한 회수 만큼 호출되었는 지 검증
  • never() - 호출되지 않았는지 여부 검증
  • atLeastOnce() - 최소한 한번은 호출되었는 지 검증
  • atLeast(int) - 최소한 지정한 회수 만큼 호출되었는 지 검증
  • atMost(int) - 최대 지정한 회수 만큼 호출되었는 지 검증

다수의 Mock 객체들이 사용되지 않은 것을 검증하고 싶은 경우에는 verifyZeroInteractions(Object ... mocks) 메서드를 사용하면 된다. 아래는 사용 예이다.

verifyZeroInteractions(mockedOne, mockedTwo, mockedThree);


Answer를 이용한 메서드 구현

Mock 객체를 사용하다보면 직접 Mock의 동작 방식을 구현해 주고 싶을 때가 있다. (사실, 필자가 개인적으로 굳이 간단한 Mock 라이브러릴 만든 이유도 이것 때문이었다.) 이런 경우 thenAnswer() 메서드와 Answer 인터페이스를 사용하면 된다. 아래 코드는 사용 예이다.

when(mockedGenerator.getNextId()).thenAnswer(new Answer<Integer>() {
    private int nextId = 0;
    public Integer answer(InvocationOnMock invocation) throws Throwable {
        return new Integer(++nextId);
    }
});

위와 같이 Answer를 사용하면, mockedGenerator의 getNextId() 메서드를 호출할 때 마다 answer() 메서드가 호출된다. 위 코드의 경우 getnextId() 메서드가 호출될 때 마다 1씩 증가된 값을 리턴하는 Anwser 구현  클래스를 리턴하였다.

만약 파라미터로 전달되는 값을 사용하고 싶다면 answer() 메서드에 전달된 InvocationOnMock을 이용하면 된다. 아래 코드는 사용 예이다.

when(authenticator.authenticate(anyString(), anyString())).thenAnswer(new Answer<Object> (){
    public Object answer(InvocationOnMock invocation) throws Throwable {
        Object[] arguments = invocation.getArguments();
        String userId = (String) arguments[0];
        String password = (String) arguments[1];
        Object authObject = null;
        // ...
        return authObject;
    }
});


@Mock 어노테이션을 이용한 코드 단순화

Mockito.mock() 메서드를 이용해서 Mock 객체를 생성하는 코드가 다소 성가시게 느껴진다면, @Mock 어노테이션을 이용해서 Mock 객체를 생성할 수 있다. 예를 들어, 아래 코드와 같이 테스트 클래스의 멤버 필드에 @Mock 어노테이션을 적용하면 해당 타입에 대한 Mock 객체가 할당된다.

@Mock 어노테이션이 동작하려면 테스트가 실행되기 전에 @Mock 어노테이션이 적용된 필드에 Mock 객체를 할당하도록 해 주어야 한다. JUnit 4 버전의 경우 @RunWith 어노테이션에서 MockitoJUnit44Runner.class를 값으로 지정해주면 된다.

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;

@RunWith(MockitoJUnit44Runner.class)
public class WriteArticleServiceImplTest {

    @Mock Authenticator authenticator;
    @Mock ArticleDao mockedDao;
    @Mock IdGenerator mockedGenerator;
   
    @Test
    public void setup() {
        when(authenticator.authenticate(anyString(), eq("password"))).thenReturn(null);
        ...
    }

}

또는 테스트가 실행되기 전에 명시적으로 MockitoAnnotations.initMocks(this) 메서드를 호출해주면 된다.

public class WriteArticleServiceImplTest {

    @Mock Authenticator authenticator;
    @Mock ArticleDao mockedDao;
    @Mock IdGenerator mockedGenerator;
   
    @Before
    public void test() {
        MockitoAnnotations.initMocks(this);
    }
   
    @Test
    public void writeArticle() {
        when(authenticator.authenticate(anyString(), eq("password"))).thenReturn(null);




관련 사이트:






 

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 김제준 2009.07.22 17:00 신고  댓글주소  수정/삭제  댓글쓰기

    잘 보고 갑니다.
    다음에 개발할때 적용해 봐야겠네요 ^^

  2. harebox 2009.07.27 11:34 신고  댓글주소  수정/삭제  댓글쓰기

    자세한 소개 감사합니다 ^^

  3. beyondj2ee 2010.09.03 17:25 신고  댓글주소  수정/삭제  댓글쓰기

    ㅋㅋ 역시! 범균님이시네요
    정말 깔끔하게 정리 해주셔서 감사 합니다.

  4. bluepoet 2012.03.06 16:56 신고  댓글주소  수정/삭제  댓글쓰기

    범균님~ 좋은글 잘 보고 갑니다.

    요즘 부쩍 테스트케이스에 대한 관심이 많아져서요~^^

  5. bluepoet 2013.04.10 13:54 신고  댓글주소  수정/삭제  댓글쓰기

    테스트케이스를 새로운 마음으로 작성하면서

    예전에 잊어버렸던 부분을 범균님 포스팅을 통해 테스트해보면서

    다시 상기하고 있습니다.

    역시 기록은 중요하네요. ^^

  6. C사 개발자 2015.08.10 17:01 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다! 좋은 참고가 되었습니다.

  7. 김병희 2016.09.19 06:09 신고  댓글주소  수정/삭제  댓글쓰기

    junit도 이제 막 시작했는데, MockTest는 아무리 구글구글해도 확실한 감이 잡히지 않았습니다.
    선생님의 글을 보니 찬찬히 보면 목 감을 잡을 수도 있지 않을까 기대가 되네요.
    감사합니다.

  8. fasdgoc 2016.11.10 19:08 신고  댓글주소  수정/삭제  댓글쓰기

    ㄷㄷㄷ 감사합니다