주요글: 도커 시작하기
반응형

세 번째 TDD 연습에서는 테스트 코드 자체를 좀 정리할 것이다. 연습1과 2를 통해서 TranscodingServiceImpl 클래스의 정상 동작인 경우와 비정상 동작인 경우에 대해서 테스트를 진행하였다. 그런데 테스트 코드를 만들면서 중복된 코드들이 많이 출현하였다.


먼저 다음의 두 메서드를 비교해 보자.


    @Test

    public void transcodeSuccessfully() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);


        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);


        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(mediaSourceCopier, only()).copy(jobId);

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

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

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

                mockThumbnails, jobId);

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

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);


        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);


        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());

        assertFalse(job.isSuccess());

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

        assertNotNull(job.getOccurredException());


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

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

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

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

                mockThumbnails, jobId);

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

    }

}


굵게 표시한 부분은 테스트를 위한 Mock 객체를 초기화하는 부분으로 다른 테스트 메서드도 일부 다른 부분이 있지만, 거의 유사하다. 먼저 다음과 같이 코드를 수정한다.

  • mockMultimediaFile 변수, mockMultimediaFiles 변수, mockThumbnails 변수를 필드로 변경한다.
    • 각 테스트 메서드에서 세 변수 선언 부분을 삭제한다.

변경된 코드는 다음과 같다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    ...

    // 각 테스트 메서드에서 공통으로 사용되는 로컬 변수를 필드로 전환

    private File mockMultimediaFile = mock(File.class);

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

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

    ...

    @Test

    public void transcodeSuccessfully() {

        // 필드로 바뀐 로컬 변수 선언 삭제됨

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

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

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

                mockMultimediaFiles);

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

                mockThumbnails);

        ...

        // verify에서 필드 사용

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

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

        ...

    }


    // 나머지 테스트 코드도 동일하게 변경

    ...

    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

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

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

                mockMultimediaFiles);

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

                mockThumbnails);

        ...

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

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

        ...

    }

}


뭔가 바꿨으니 테스트를 실행해 본다. 녹색! 문제가 발생하지 않았다. 이제 다음 중복으로 넘어가자. 또 다른 중복되는 부분은 Mock 객체의 행위를 지정하는 부분이다. (when().thenReturn() 류의 코드.) 이런 코드들 역시 각 테스트 메서드에서 중복해서 출현하므로 setUp() 메서드로 이동시키자.



    @Before

    public void setup() {

        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,

                transcoder, thumbnailExtractor, createdFileSender,

                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);


        // 각 테스트 메서드의 중복되는 초기화 코드를 setup으로 이동

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

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

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

                mockMultimediaFiles);

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

                mockThumbnails);


        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() {

        // 초기화 코드 제거됨

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        transcodingService.transcode(jobId);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        // 초기화 코드 제거됨

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

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

                mockException);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

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

                mockException);

        ...

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(createdFileSender).store(

                mockMultimediaFiles, mockThumbnails, jobId);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);

        ...

    }


뭔가를 바꿨으니 또 테스트다. 녹색! 통과다.


이제 실패의 경우를 테스트하는 5개의 테스트 메서드에서 중복을 제거하자. 실패 시나리오를 테스트하는 두 메서드를 보자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(createdFileSender).store(

                mockMultimediaFiles, mockThumbnails, jobId);


        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.STORING, job.getLastState());

        assertNotNull(job.getOccurredException());


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

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

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

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

                mockThumbnails, jobId);

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

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());

        assertFalse(job.isSuccess());

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

        assertNotNull(job.getOccurredException());


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

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

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

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

                mockThumbnails, jobId);

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

    }


굵게 표시한 부분이 중복된 코드이다. 이 중복을 다음과 같이 제거해 보자.

  • mockException 로컬 변수를 필드로 전환
  • try - catch 블록과 job의 상태를 검사하는 코드를 메서드로 분리
이번엔 두 가지를 한 번에 적용한 결과를 보자.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    private List<File> mockThumbnails = new ArrayList<File>();
    private RuntimeException mockException = new RuntimeException();
    ...
    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        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);
    }

    private void executeFailingTranscodeAndAssertFail(State expectedLastState) {
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());
        assertFalse(job.isSuccess());
        assertEquals(expectedLastState, job.getLastState());
        assertNotNull(job.getOccurredException());
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);

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

    @Test
    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.EXTRACTINGTHUMBNAIL);

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

    @Test
    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
        doThrow(mockException).when(createdFileSender).store(
                mockMultimediaFiles, mockThumbnails, jobId);

        executeFailingTranscodeAndAssertFail(Job.State.STORING);

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

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());

        executeFailingTranscodeAndAssertFail(Job.State.NOTIFYING);

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

테스트를 실행해서 정상적으로 동작하는 지 확인한다. 실패 시나리오 테스트 메서드가 보다 간결해졌다.

또 다른 중복은 TranscodingService의 transcode() 메서드를 호출하기 전에 Job 객체가 대기 상태인지 확인하는 코드이다.

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());

이 코드가 모든 테스트 코드에 포함되어 있진 않지만 중복해서 들어가 있다. 따라서, 다음과 같이 중복된 부분을 별도의 메서드로 분리해 내자.

    @Test
    public void transcodeSuccessfully() {
        assertJobIsWaitingState();
        transcodingService.transcode(jobId);
        ...
    }

    private void assertJobIsWaitingState() {
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);
        assertJobIsWaitingState();
        executeFailingTranscodeAndAssertFail(Job.State.NOTIFYING);
        ...
    }

거의 정리가 되어 가는 느낌이다. Robert C. Martin의 표현을 빌자면, Lovely해 지고 있다.

하지만, 여전히 불만족스러운 부분이 있는데, 그 부분은 바로 협업 객체가 호출되는지의 여부를 확인하는 verify 부분이다. 각 메서드의 verify 부분만 모아보면 아래와 같다.

    @Test
    public void transcodeSuccessfully() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        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);
    }


    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }

뭔가 굉장히 비슷하다. 이 놈들을 어찌해야 하나 고민을 좀 했다. 뭔가 verify를 해 주는 메서드를 하나 만들고, 그 메서드에서 파라미터로 각 협업 객체의 verify 방식을 지정해 줘야 할까도 생각해 봤다. 대충 다음과 같은 모양의 메서드를 상상해 봤다.

private void verifyCollaboration(boolean transcoderNever, boolean thumbnailExtractorNever, 
        boolean createdFileSenderNever, boolean jobResultNotifierNever) {
    if (transcoderNever)
        verify(transcoder, never()).transcode(any(File.class), anyLong());
    else
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
    ... // 비슷한 코드
}

파라미터가 네 개이기 때문에 순서를 잘못 적는 실수를 범할 수 있을 것도 같다. 음,,,,, 네 개의 파라미터 대신에 이 파라미터들을 필드로 갖는 데이터 구조를 만들어서 전달하면 어떨까? 오케이 그게 좋을 것 같다. 그럼, 중복도 없애고 파라미터 순서에 따른 실수도 덜하면서 의미도 잃지 않을 수 있을 것 같다. 이를 반영한 코드는 아래와 같다.

    @Test
    public void transcodeSuccessfully() {
        assertJobIsWaitingState();

        transcodingService.transcode(jobId);

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

        VerifyOption verifyOption = new VerifyOption();
        verifyCollaboration(verifyOption);
    }

    private void verifyCollaboration(VerifyOption verifyOption) {
        verify(mediaSourceCopier, only()).copy(jobId);

        if (verifyOption.transcoderNever)
            verify(transcoder, never()).transcode(any(File.class), anyLong());
        else
            verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        ... // 비슷한 코드로 각 협업 객체 verify
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.transcoderNever = true;
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }
    ...
    public class VerifyOption {
        public boolean transcoderNever;
        public boolean thumbnailExtractorNever;
        public boolean createdFileSenderNever;
        public boolean jobResultNotifierNever;
    }
}

메서드를 사용해서 코드를 하나씩 바꿀 때 마다 테스트를 실행해서 안전하게 동작하는지 확인한다. 모든 테스트가 정상적으로 통과된다. 야호!

테스트 코드 정리가 끝났으니, 다음번엔 각 협업 객체들을 점진적으로 구현해 나갈 차례이다.



+ Recent posts