Mockito를 활용한 단위 테스트 정리
이번 포스팅에서는 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나 외부 서비스에 의존하지 않고, 순수한 비즈니스 로직 단위 테스트를 편리하게 작성할 수 있다. 이렇게 격리된 단위 테스트는 빠르고 신뢰성 있으며, 리팩토링 과정에서도 안전하게 코드 품질을 유지하는 데 도움이 된다.
목키토.. 뭔가 이름이 열받아용