
도메인 객체들을 모두 만들고 서비스 레이어 코드를 작성하던 중 문득 이런 생각이 들었다.
“도메인 테스트는 했는데… 서비스 코드는 어떻게 테스트하지?”
구글링을 하다 보니 대부분의 예시에서 Mockito라는 도구가 등장했다.
나는 지금까지 프리코스에서 단위 테스트 위주로만 개발했고 DB나 외부 협력 객체를 따로 Mock 해야 하는 상황을 마주한 적이 없었다.
그러나 Mockito를 알고 나서 “아 서비스 테스트는 이렇게 하는 거구나!” 하고 감탄했다.
이 글은 내가 처음 Mockito를 접하며 정리한 내용을 기록해두기 위한 글이다.
나중에 다시 돌아왔을 때도 한눈에 이해할 수 있도록 최대한 쉽게 풀어 썼다.
Mockito는 단위 테스트(Unit Test)에서 등장한다.
단위 테스트란?
외부 의존성을 끊고 + 원하는 상황을 마음대로 만들고 싶을 때
→ Mocking 프레임워크 = Mockito가 등장한다.
Mockito는 테스트 중에 "협력 객체의 행동을 마음대로 조작할 수 있는 가짜 객체(Mock)"를 만들어주는 프레임워크이다.
Mock 객체는
즉, Mockito는 테스트에서 현실에서는 만들기 어려운 상황을 즉시 재현하는 기술을 제공한다.
Mock을 쓰면 다음과 같은 상황을 연출할 수 있다.
서비스 테스트에서 매우 강력한 도구가 되는 이유다.
Mockito는 단순히 가짜 객체(Mock)만 만드는 게 아니라, 상황 조작을 위한 다양한 개념들이 있다.
Repository, Policy 등을 Mock으로 만든다예:
@Mock
MemberRepository memberRepository;
Mock에게 “이렇게 동작해라”라고 행동을 정의하는 것예:
given(memberRepository.findById(1L))
.willReturn(Optional.of(Member.overdueMember(1L)));
Mock이 섞인 형태예:
@Spy
LoanPolicy loanPolicy;
예:
InMemoryRepository (직접 구현하는 것, Mockito가 만들지는 않음)
서비스는 보통 이런 흐름으로 동작한다.
서비스 로직
↓
Repository 조회
↓
도메인 규칙 판단
↓
Repository 저장
↓
외부 후처리
여기서 핵심은..
서비스는 여러 객체들과 협력하며 동작하고 이 객체들은 보통 DB 연결, 네트워크 통신 같은 느리고 무거운 작업이다.
그래서 테스트에서 이걸 실제로 호출하면 이런 문제가 생긴다.
따라서, 테스트에서는 “상황을 내 마음대로 연출할 수 있는 가짜 객체(Mock)”가 훨씬 유리하다.
이걸 자동으로 만들어주는 라이브러리가 Mockito다.
Mockito로 테스트할 때 꼭 이해해야 하는 세 가지 기능.
@Mock)가짜 객체를 만든다.
@Mock
MemberRepository memberRepository;
Mock이 어떻게 반응해야 할지 정의한다.
given(memberRepository.findById(1L))
.willReturn(Optional.of(member));
또는 예외 상황도 조작 가능하다.
given(storedBookRepository.findById(10L))
.willThrow(new BookNotFoundException());
즉, 테스트의 상황을 만드는 역할
서비스가 Mock에게 “정확히 어떤 메시지를 보냈는지” 확인한다.
verify(mailSender).sendWelcomeMail(email);
또는 호출되면 안 되는 경우에는
verify(loanRepository, never()).save(any());
즉, 서비스의 행동을 검증하는 역할
위에서 Mockito의 개념을 모두 정리했다면,
이제 실제로 내가 작성한 BookServiceTest에 그 개념들이 어떻게 적용되었는지 살펴보자.
특히 처음 보면 어렵게 느껴지는 두 부분
ArgumentCaptorthenAnswer이 둘은 서비스 테스트에서 매우 자주 쓰이기 때문에 꼭 이해하고 넘어가면 좋다.
아래는 실제 코드 일부다.
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
private BookRepository bookRepository;
private BookService bookService;
@BeforeEach
void setUp() {
bookService = new BookService(bookRepository);
}
@Test
@DisplayName("registerBook: 새 도서를 등록하면 Book.registerNew로 생성된 엔티티를 저장하고 생성된 ID를 반환한다")
void savesBookAndReturnsId() throws Exception {
// given
String title = "클린 코드";
String author = "로버트 마틴";
int initialCount = 3;
when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> {
Book book = invocation.getArgument(0);
Field idField = Book.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(book, 1L);
return book;
});
// when
Long savedId = bookService.registerBook(title, author, initialCount);
// then
assertThat(savedId).isEqualTo(1L);
ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
verify(bookRepository, times(1)).save(captor.capture());
Book savedBookArg = captor.getValue();
assertThat(savedBookArg.getTitle()).isEqualTo(title);
assertThat(savedBookArg.getAuthor()).isEqualTo(author);
assertThat(savedBookArg.getStoredBooks().size()).isEqualTo(initialCount);
}
@Test
@DisplayName("addStoredBooks: 기존 도서에 소장본을 추가하면 개수가 증가한다")
void increasesCopyCount() {
// given
Book book = Book.registerNew("클린 코드", "로버트 마틴", 1);
Long bookId = 1L;
when(bookRepository.findById(bookId)).thenReturn(Optional.of(book));
// when
bookService.addStoredBooks(bookId, 2);
// then
assertThat(book.getStoredBooks().size()).isEqualTo(3);
verify(bookRepository, times(1)).findById(bookId);
verify(bookRepository, never()).save(any());
}
}
@Mock
private BookRepository bookRepository;
실제 DB와 연결되는 진짜 BookRepository를 쓰지않고 Mockito가 만들어준 가짜 레포지토리를 테스트에 주입한다.
findById가 특정 Book을 반환하게 만들기when(bookRepository.findById(bookId))
.thenReturn(Optional.of(book));
save()가 DB처럼 ID를 채워 넣게 만들기 (thenAnswer)이 부분이 처음 보면 가장 어렵다.
when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> {
Book book = invocation.getArgument(0);
Field idField = Book.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(book, 1L);
return book;
});
여기서 하고 싶은 건 단 하나다.
JPA처럼
save()가 호출되면Book객체에id값이 자동으로 채워지도록 흉내내기.
하지만 테스트에서는 DB가 없기 때문에 JPA가 실제로 ID를 넣어주지 않는다.
그래서 테스트 코드에서 직접 넣어줘야 하는데 그걸 가능하게 하는 것이 thenAnswer다.
thenAnswer를 흐름을 보면
invocation.getArgument(0) : save()에 전달된 Book 객체reflection으로 id 필드 접근 가능하게 만들기 (리플렉션이 궁금하면 클릭)Book 객체의 id 값을 1L로 강제 세팅하기Book을 반환하기이렇게 단계를 나눠보면 단순하다.
즉, “save()를 호출하면 Book 객체에 id=1L을 넣어서 반환해라” 라는 Custom 행동을 정의한 것이다.
save()를 실제로 한 번 호출했는지 확인verify(bookRepository, times(1)).save(any());
verify(bookRepository, never()).save(any());
Book 객체가 정확했는지 확인 (ArgumentCaptor)ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
verify(bookRepository).save(captor.capture());
Book savedBook = captor.getValue();
이 부분도 처음보면 햇갈릴 수 있다.
이것을 사용하는 이유는 Repository.save()가 “호출되었다”만 검사하면 충분하지 않다.
그렇기에 우리는 서비스가 생성한 Book 객체의 값이 정확한지도 확인해야 한다.
예를 들어 실제로:
title이 같은지author가 같은지이런 값 검증은 save()에 전달된 Book 객체를 직접 꺼내서 확인해야 가능하다.
그래서 ArgumentCaptor가 등장한다.
즉, ArgumentCaptor 역할은 Repository.save()에 전달된 그 객체 그대로를 테스트 코드로 가져와서 검증할 수 있게 해주는 도구
예전에는 테스트 코드를 작성하지 않고 프로그램을 만들다 보니 “이게 왜 안 되지?”, “이게 왜 되지?”와 같은 혼란을 자주 겪곤 했다.
이번 경험을 통해 그런 상황을 막기 위해서는 결국 내가 의도한 대로 코드가 동작하는지 스스로 증명하는 과정이라는 것을 느꼈다.
서비스 레이어 테스트는 처음이라 낯설고 시간이 오래 걸렸지만 그 과정에서 얻은 배움은 예상보다 훨씬 크고 값졌다.
앞으로 더 복잡한 기능을 만들 때에도 이번 경험을 기반 삼아 테스트를 통해 설계를 검증하고 예측 가능한 코드를 작성하는 개발자로 계속 성장하고 싶다.
Springboot - 서비스 단위 테스트
[Spring Boot] Service 계층의 단위 테스트 코드 작성
[Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3)
스프링 테스트코드 작성법