코딩딩/Spring

Mockito를 활용한 단위 테스트 정리

전낙타 2024. 12. 11. 05:05

이번 포스팅에서는 Mockito를 활용해 Mock 객체를 만들고, 원하는 동작을 지정(Stubbing)하고, 메서드 호출 여부나 호출 순서를 검증하는 방법에 대해 정리해보려고 한다. 예제 코드는 JUnit5와 Mockito를 사용하고 있으며, @ExtendWith(MockitoExtension.class)를 통해 Mockito 관련 설정을 편리하게 적용했다.

Mockito 기본 개념

Mock 객체란?

실제 객체처럼 동작하지만, 프로그래머가 원하는 대로 그 객체의 행위를 설정(Stubbing)할 수 있는 가짜 객체다. Mockito를 사용하면 아주 간단히 Mock 객체를 만들고 관리할 수 있다. Mock 객체를 통해 외부 의존성을 가진 코드(예: DB 접근, 외부 API 호출)를 테스트 시점에 격리시키고, 원하는 시나리오에 맞춰 동작을 흉내낼 수 있다.

테스트 예제 살펴보기

아래는 StudyServiceTest 클래스의 일부 코드다. 여기서는 MemberService, StudyRepository를 Mock으로 만들어, StudyService를 테스트하고 있다. MemberService나 StudyRepository는 실제 DB 접근이나 외부 연동을 하는 서비스라고 가정하고, 테스트에서는 이 로직을 격리하기 위해 Mock을 사용한다.

@ExtendWith(MockitoExtension.class)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StudyServiceTest {

    @Mock MemberService memberService;
    @Mock StudyRepository studyRepository;

    Member member;
    Study study;

    @BeforeEach
    void set_up() {
        member = Member.builder().id(1L).email("test@test.com").build();
        study = new Study(10);
    }

    @Test
    void any_long_test() {
        // memberService.findById(anyLong()) 호출 시 member를 감싼 Optional 리턴하기
        when(memberService.findById(anyLong())).thenReturn(Optional.of(member));

        Member byId1 = memberService.findById(1L)
            .orElseThrow(MemberNotFoundException::new);
        Member byId2 = memberService.findById(2L)
            .orElseThrow(MemberNotFoundException::new);

        // 두 번의 호출 모두 같은 member를 반환하는지 검증
        assertEquals(member, byId1);
        assertEquals(member, byId2);
    }

    ...
}

Mock Stubbing

when(memberService.findById(anyLong())).thenReturn(Optional.of(member));는 흔히 말하는 Stubbing이다. "어떤 메서드가 호출될 때 어떤 값을 리턴하거나 예외를 던질지"를 정하는 것. Stubbing을 하지 않으면 Mock 객체의 기본 동작은 다음과 같다:

  • 모든 객체 리턴 타입: null
  • Optional 타입: Optional.empty()
  • primitive 타입: 기본값(0, false 등)
  • 컬렉션: 비어있는 컬렉션
  • Void 메서드: 아무 일도 일어나지 않음

Stubbing을 통해 findById(anyLong()) 호출 시 Optional.of(member)를 반환하도록 설정했기 때문에, 이후 호출에서도 언제나 이 값이 반환된다.

@Test
void do_throw_test() {
    // 특정 상황에서 예외 던지기
    doThrow(new IllegalArgumentException()).when(memberService).validate(1L);

    assertThrows(IllegalArgumentException.class, () -> memberService.validate(1L));
    // 2L 호출시에는 예외가 발생하지 않음
    memberService.validate(2L);
}

이 예제에서는 void 타입 메서드를 대상으로 예외를 던지도록 설정했다. doThrow(...).when(...) 패턴을 사용해 특정 인자에 대해 호출할 때 예외를 발생시킬 수 있다.

@Test
void what_the_test() {
    when(memberService.findById(any()))
        .thenReturn(Optional.of(member))                  // 첫 번째 호출
        .thenThrow(MemberNotFoundException.class)         // 두 번째 호출
        .thenReturn(Optional.empty());                    // 세 번째 호출

    // 1차 호출: member 반환
    assertEquals(member, memberService.findById(1L).orElseThrow(MemberNotFoundException::new));
    // 2차 호출: 예외 발생
    assertThrows(MemberNotFoundException.class, () -> {
        memberService.findById(2L).orElseThrow(MemberNotFoundException::new);
    });
    // 3차 호출: Optional.empty()
    assertEquals(Optional.empty(), memberService.findById(3L));
}

thenReturn()이나 thenThrow()는 이렇게 체이닝할 수 있어서, 같은 메서드가 여러 번 호출될 때 각각 다른 결과를 내려줄 수 있다.

Mock 객체 검증(Verify)

@Test
void mock_verify_test() {
    // given
    StudyService studyService = new StudyService(memberService, studyRepository);
    when(memberService.findById(1L)).thenReturn(Optional.of(member));
    when(studyRepository.save(study)).thenReturn(study);

    Member byId = memberService.findById(1L)
        .orElseThrow(MemberNotFoundException::new);

    // when
    studyService.createNewStudy(byId.getId(), study);

    // then
    // 특정 메서드가 몇 번 호출되었는지, 전혀 호출되지 않았는지 검증
    verify(memberService, times(1)).notify(study);
    verify(memberService, times(1)).notify(member);
    verify(memberService, never()).validate(any());
}

verify()를 사용하면 Mock 메서드가 기대한 만큼 호출됐는지 검증할 수 있다.

  • times(1) : 정확히 한 번 호출이어야 함
  • never() : 호출되지 않아야 함

호출 순서를 검증하는 것도 가능하다.

InOrder inOrder = inOrder(memberService);
inOrder.verify(memberService).notify(study); // 먼저 study로 notify
inOrder.verify(memberService).notify(member); // 그 다음 member로 notify

이렇게 하면 notify(study)가 먼저 호출되고, 그 다음에 notify(member)가 호출되었는지 확인할 수 있다.

BDD 스타일로 Mockito 사용하기

Mockito는 BDD(Behavior-Driven Development) 스타일을 지원한다. when -> given, thenReturn -> willReturn, verify -> then().should() 형태로 좀 더 자연스럽게 읽히는 코드를 작성할 수 있다.

@Test
void BDD_test() {
    // given
    StudyService studyService = new StudyService(memberService, studyRepository);
    given(memberService.findById(1L)).willReturn(Optional.of(member));
    given(studyRepository.save(study)).willReturn(study);

    Member byId = memberService.findById(1L)
        .orElseThrow(MemberNotFoundException::new);

    // when
    studyService.createNewStudy(byId.getId(), study);

    // then
    then(memberService).should(times(1)).notify(study);
    then(memberService).should(times(1)).notify(member);
    // notify 호출 순서나 횟수등도 동일하게 검증 가능
}

이렇게 하면 읽기도 좋고, 요구사항을 코드로 표현하기 수월해진다.

정리

Mockito를 사용하면 다음과 같은 이점이 있다.

  • Mock 객체 생성: @Mock 어노테이션과 @ExtendWith(MockitoExtension.class)로 간단히 Mock 객체를 주입받을 수 있다.
  • Stubbing: when() / given()을 통해 특정 메서드 호출 시 반환값 혹은 예외를 설정할 수 있다.
  • Verify: verify() / then().should()를 통해 메서드 호출 여부, 횟수, 순서를 검증할 수 있다.

이를 통해 DB나 외부 서비스에 의존하지 않고, 순수한 비즈니스 로직 단위 테스트를 편리하게 작성할 수 있다. 이렇게 격리된 단위 테스트는 빠르고 신뢰성 있으며, 리팩토링 과정에서도 안전하게 코드 품질을 유지하는 데 도움이 된다.


목키토.. 뭔가 이름이 열받아용