도서 어플리케이션 과제를 진행하면서 테스트 코드에 맞는 구현체과 프로덕션에 맞는 구현체를 분리하여 구현하는 경험을 했다. 그 과정을 기록하려고 한다.
"도서가 반납되면 '도서 정리중' 상태에서 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을 받는 이유가 테스트 코드만을 위한 것 같다고 하셨다(사실 그게 맞다..). 완벽히 테스트 코드와 프로덕션 코드를 분리하기 위해 다음 링크를 참고해서 다시 리팩토링을 진행했다.
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);
}
}
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);
}
}
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);
}
}
이제 프로덕션 코드에는 테스트를 위한 로직이 전혀 들어가지 않는다.
잘 보고 갑니다 ^.^