테스트를 위한 인터페이스 분리

qufdl·2023년 10월 17일
1

데브코스TIL

목록 보기
1/2

개요

도서 어플리케이션 과제를 진행하면서 테스트 코드에 맞는 구현체과 프로덕션에 맞는 구현체를 분리하여 구현하는 경험을 했다. 그 과정을 기록하려고 한다.

문제

"도서가 반납되면 '도서 정리중' 상태에서 5분이 지난 도서는 '대여 가능'으로 바뀌어야 한다" 라는 요구사항이 있었다. 처음에는 테스트 코드를 전혀 고려하지 않아서 아래와 같이 지연 시간을 5분으로 고정한 메서드를 구현했다.

    public void returnBookByBookNo(Long bookNo) throws BookNotExistException {
        ...
        scheduleTask(book);
    }

    private void scheduleTask(Book book) {
        TimerTask timerTask = new TimerTask() {
            @Override
            public void run() {
                book.changeStatus(Status.AVAILABLE);
                repository.saveBook(book);
            }
        };
        Timer timer = new Timer(true);
        timer.schedule(timerTask, 300000);
    }

위처럼 구현 후 책 반납에 대한 테스트코드를 작성하려하니 테스트가 5분이 지나서야 끝이 났다. 기다리다가 답답해서 시간을 그냥 줄여버렸는데, 프로덕션에 테스트를 위한 코드가 있으면 안된다는 조언을 받았다.

첫 번째 시도(함수 인자로 시간 입력 받기)

지연 시간을 메서드 인자로 받으면 되잖아?

라는 단순한 생각으로 아래와 같이 리팩토링을 진행했다.

    public void returnBookByBookNo(Long bookNo, int time) throws BookNotExistException {
        ...
        scheduleTask(book, time);
    }

    private TimerTask wrap(Runnable runnable) {
        return new TimerTask() {
            @Override
            public void run() {
                runnable.run();
            }
        };
    }

    private void scheduleTask(Book book, int time) {
        Runnable bookTask = () -> {
            book.toAvailable();
            repository.saveBook(book);
        };
        Timer timer = new Timer(true);
        timer.schedule(wrap(bookTask), time);
    }

프로덕션 코드에는 time에 5분을, 테스트 코드에서는 시간을 1초로 설정해서 테스트를 진행했다. 잘 동작해서 뿌듯해하고 있었는데, 멘토님께서 returnBookByBookNo 메서드가 time을 받는 이유가 테스트 코드만을 위한 것 같다고 하셨다(사실 그게 맞다..). 완벽히 테스트 코드와 프로덕션 코드를 분리하기 위해 다음 링크를 참고해서 다시 리팩토링을 진행했다.

두 번째 시도(인터페이스로 분리)

  1. 스케줄링된 작업을 처리하기 위한 메서드를 정의하는 BookTaskScheduler 생성
public class BookTaskScheduler implements BookScheduler{
    private final Timer timer = new Timer(true);

    @Override
    public void scheduleBookTask(Runnable bookTask) {
        TimerTask timerTask = wrap(bookTask);
        timer.schedule(timerTask, 300000);
    }
}
  1. 프로덕션 코드에서 사용할 BookTaskScheduler 구현
public class BookTaskScheduler implements BookScheduler{
    private final Timer timer = new Timer(true);

    @Override
    public void scheduleBookTask(Runnable bookTask) {
        TimerTask timerTask = wrap(bookTask);
        timer.schedule(timerTask, 300000);
    }
}
  1. 테스트시 사용 할 BookTestScheduler 구현
public class BookTestScheduler implements BookScheduler{
    private final Timer timer = new Timer(true);

    @Override
    public void scheduleBookTask(Runnable bookTask) {
        TimerTask timerTask = wrap(bookTask);
        timer.schedule(timerTask, 500);
    }
}

이제 프로덕션 코드에는 BookTaskScheduler을 생성하여 사용하고, 테스트 코드에서는 BookTestScheduler을 생성하여 사용하면 프로덕션 코드의 변경 없이 원하는 동작을 수행할 수 있다!

도서 반납 프로덕션 코드

    public void returnBookByBookNo(Long bookNo, BookScheduler bookScheduler) {// 컨트롤러로부터 BookTaskScheduler을 주입 받는다
        Book book = findBookByBookNo(bookNo);
        if (book.isAvailableToReturn()) {
            book.toOrganizing();
            repository.saveBook(book);
            bookScheduler.scheduleBookTask(getBookTask(book));
        }
    }

도서 반납 테스트코드

    @Test
    @DisplayName("도서 반납 테스트")
    void testReturnBook() {
        Long bookNo = 1L;
        Book book = new Book(1L, "제목1", "작가1", 123);
        book.toBorrowed();
        bookScheduler = new BookTestScheduler();

        when(repository.findBookByBookNo(bookNo)).thenReturn(Optional.of(book));
        try {
            bookService.returnBookByBookNo(bookNo, bookScheduler);
            assertThat(book.getStatus()).isEqualTo(Status.ORGANIZING);
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            verify(repository, times(2)).saveBook(book);
            assertThat(book.getStatus()).isEqualTo(Status.AVAILABLE);
        }
    }

이제 프로덕션 코드에는 테스트를 위한 로직이 전혀 들어가지 않는다.

1개의 댓글

comment-user-thumbnail
2023년 10월 18일

잘 보고 갑니다 ^.^

답글 달기