처음 만난 Mockito, 스프링 서비스 테스트는 이렇게 시작했다.

이성민·2025년 11월 17일

woowacourse

목록 보기
10/12
post-thumbnail

이 글을 쓰게된 이유

도메인 객체들을 모두 만들고 서비스 레이어 코드를 작성하던 중 문득 이런 생각이 들었다.

“도메인 테스트는 했는데… 서비스 코드는 어떻게 테스트하지?”

구글링을 하다 보니 대부분의 예시에서 Mockito라는 도구가 등장했다.
나는 지금까지 프리코스에서 단위 테스트 위주로만 개발했고 DB나 외부 협력 객체를 따로 Mock 해야 하는 상황을 마주한 적이 없었다.

그러나 Mockito를 알고 나서 “아 서비스 테스트는 이렇게 하는 거구나!” 하고 감탄했다.

이 글은 내가 처음 Mockito를 접하며 정리한 내용을 기록해두기 위한 글이다.
나중에 다시 돌아왔을 때도 한눈에 이해할 수 있도록 최대한 쉽게 풀어 썼다.


Mockito는 어떤 상황에서 등장하는가?

Mockito는 단위 테스트(Unit Test)에서 등장한다.

단위 테스트란?

  • 하나의 클래스 또는 메서드만 정확히 검증하는 테스트
  • 외부 시스템(DB, 네트워크, 파일 시스템 등)이 개입하면 테스트가 느리고 불안정해짐

외부 의존성을 끊고 + 원하는 상황을 마음대로 만들고 싶을 때
→ Mocking 프레임워크 = Mockito가 등장한다.


Mockito란?

Mockito는 테스트 중에 "협력 객체의 행동을 마음대로 조작할 수 있는 가짜 객체(Mock)"를 만들어주는 프레임워크이다.

Mock 객체는

  • 실제 객체처럼 생겼지만
  • 실제 기능은 없고
  • 내가 정의한 방식으로만 동작한다.

즉, Mockito테스트에서 현실에서는 만들기 어려운 상황을 즉시 재현하는 기술을 제공한다.

Mock을 쓰면 다음과 같은 상황을 연출할 수 있다.

  • DB 조회 결과를 강제로 없다/있다로 만들기
  • 저장 메서드가 호출되면 오류를 던지게 만들기
  • 특정 값이 입력되면 특정 객체를 반환하게 만들기
  • 외부 API가 timeout 났다는 상황 만들기
  • 메일 발송 메서드를 호출했는지 검증하기

서비스 테스트에서 매우 강력한 도구가 되는 이유다.


Mock / Stub / Spy / Fake 차이

Mockito는 단순히 가짜 객체(Mock)만 만드는 게 아니라, 상황 조작을 위한 다양한 개념들이 있다.

1. Mock

  • 실제 객체를 흉내낸 껍데기
  • 내부 로직 없음
  • Repository, Policy 등을 Mock으로 만든다

예:

@Mock
MemberRepository memberRepository;

2. Stub

  • Mock에게 “이렇게 동작해라”라고 행동을 정의하는 것
  • 테스트의 “상황 만들기” 기능

예:

given(memberRepository.findById(1L))
        .willReturn(Optional.of(Member.overdueMember(1L)));

3. Spy

  • 실제 객체 + Mock이 섞인 형태
  • 실제 메서드도 실행되지만 필요한 부분만 Mocking 가능
  • 도메인 검증보다는 정책 객체 부분적 실험할 때 사용

예:

@Spy
LoanPolicy loanPolicy;

4. Fake

  • 실제 동작을 단순화한 테스트용 구현체

예:
InMemoryRepository (직접 구현하는 것, Mockito가 만들지는 않음)


그럼 Mockito는 왜 필요한가?

서비스는 보통 이런 흐름으로 동작한다.

서비스 로직

Repository 조회

도메인 규칙 판단

Repository 저장

외부 후처리

여기서 핵심은..
서비스는 여러 객체들과 협력하며 동작하고 이 객체들은 보통 DB 연결, 네트워크 통신 같은 느리고 무거운 작업이다.

그래서 테스트에서 이걸 실제로 호출하면 이런 문제가 생긴다.

  • DB 초기 세팅 필요 → 테스트 느림
  • 외부 API의 응답이 매번 달라짐 → 테스트 불안정
  • 네트워크 오류, 메일 서버 문제 → 테스트 깨짐
  • 특정 상황(예: “연체 회원”)을 만들기 어려움

따라서, 테스트에서는 “상황을 내 마음대로 연출할 수 있는 가짜 객체(Mock)”가 훨씬 유리하다.

이걸 자동으로 만들어주는 라이브러리가 Mockito다.


Mockito의 핵심 기능 3가지

Mockito로 테스트할 때 꼭 이해해야 하는 세 가지 기능.

1. Mock 생성 (@Mock)

가짜 객체를 만든다.

@Mock
MemberRepository memberRepository;

2. Stub (행동 정의)

Mock이 어떻게 반응해야 할지 정의한다.

given(memberRepository.findById(1L))
        .willReturn(Optional.of(member));

또는 예외 상황도 조작 가능하다.

given(storedBookRepository.findById(10L))
        .willThrow(new BookNotFoundException());

즉, 테스트의 상황을 만드는 역할

3. Verify (호출 검증)

서비스가 Mock에게 “정확히 어떤 메시지를 보냈는지” 확인한다.

verify(mailSender).sendWelcomeMail(email);

또는 호출되면 안 되는 경우에는

verify(loanRepository, never()).save(any());

즉, 서비스의 행동을 검증하는 역할


내 BookServiceTest에 Mockito가 어떻게 쓰였는지

위에서 Mockito의 개념을 모두 정리했다면,
이제 실제로 내가 작성한 BookServiceTest에 그 개념들이 어떻게 적용되었는지 살펴보자.

특히 처음 보면 어렵게 느껴지는 두 부분

  • ArgumentCaptor
  • thenAnswer

이 둘은 서비스 테스트에서 매우 자주 쓰이기 때문에 꼭 이해하고 넘어가면 좋다.

아래는 실제 코드 일부다.

전체 코드 (일부)

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

1. Mock 생성 (@Mock)

@Mock
private BookRepository bookRepository;

실제 DB와 연결되는 진짜 BookRepository를 쓰지않고 Mockito가 만들어준 가짜 레포지토리를 테스트에 주입한다.

2. Stub - 원하는 상황 만들기

  • 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 행동을 정의한 것이다.

3. Verify - 서비스가 Repository에 어떤 메시지를 보냈는지 검증

  • 서비스가 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)
스프링 테스트코드 작성법

profile
BE 개발자

0개의 댓글