Mockito 심화 — verify, ArgumentCaptor, BDD 스타일로 테스트 의도 표현하기
Mock 객체가 "어떤 인수로" 호출되었는지까지 검증하고 싶다면 어떻게 해야 할까요?
when().thenReturn()으로 반환값을 지정하는 건 Mockito의 기본입니다. 하지만 실무에서는 호출 횟수를 검증하거나, 전달된 인수를 캡처해서 상세 검증 해야 하는 경우가 훨씬 많습니다. 공부하다 보니 verify와 ArgumentCaptor를 능숙하게 쓸 수 있느냐가 테스트 품질에 직접적인 차이를 만들더라고요.
Mockito 5.x 핵심 변경점
Mockito 5.x에서 가장 큰 변화는 inline mock maker가 기본 이 된 것입니다.
이전(4.x 이하):
- 기본이 subclass mock maker → final 클래스 모킹 불가
- final 클래스를 모킹하려면
mockito-extensions파일을 추가해야 했음
이후(5.x):
- inline mock maker가 기본 → final 클래스도 그냥 모킹됨
- 별도 설정 파일 불필요
// Mockito 5.x — final 클래스도 별도 설정 없이 모킹
final class ExternalPaymentClient {
public PaymentResult pay(int amount) {
// 외부 API 호출
return new PaymentResult(true);
}
}
@Test
void final_클래스도_모킹할_수_있다() {
ExternalPaymentClient client = mock(ExternalPaymentClient.class);
when(client.pay(anyInt())).thenReturn(new PaymentResult(false));
assertThat(client.pay(10000).isSuccess()).isFalse();
}
Spring Boot 3.x를 사용한다면 Mockito 5.x가 기본 포함되어 있으므로, 별도로 신경 쓸 것 없이 final 클래스 모킹이 가능합니다.
verify() 심화 — 호출 검증의 다양한 방법
verify()는 ** 모킹된 객체의 메서드가 호출되었는지 검증 **합니다. 단순히 "호출됐는가?"를 넘어서 다양한 조건을 걸 수 있습니다.
호출 횟수 검증
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Mock
private EmailSender emailSender;
@Mock
private SmsSender smsSender;
@InjectMocks
private NotificationService notificationService;
@Test
void 이메일을_정확히_한_번_발송한다() {
notificationService.notifyOrderComplete(order);
// 정확히 1번 호출됨 (기본값)
verify(emailSender, times(1)).send(any(Email.class));
// times(1)은 기본값이라 생략 가능
verify(emailSender).send(any(Email.class));
}
@Test
void 취소_알림은_이메일과_SMS_모두_발송한다() {
notificationService.notifyOrderCancelled(order);
verify(emailSender, times(1)).send(any(Email.class));
verify(smsSender, times(1)).send(any(Sms.class));
}
@Test
void 일반_주문에는_SMS를_발송하지_않는다() {
notificationService.notifyOrderComplete(order);
// 한 번도 호출되지 않음을 검증
verify(smsSender, never()).send(any(Sms.class));
}
@Test
void 대량_주문은_관리자에게_최소_1번_알린다() {
notificationService.notifyBulkOrder(bulkOrder);
// 최소 1번 이상 호출
verify(emailSender, atLeast(1)).send(any(Email.class));
// 최대 3번까지 호출
verify(emailSender, atMost(3)).send(any(Email.class));
}
}
호출 순서 검증
여러 메서드가 ** 특정 순서로 호출 **되었는지도 검증할 수 있습니다.
@Test
void 결제_후_알림_순서를_검증한다() {
orderService.processOrder(order);
// InOrder로 순서 검증
InOrder inOrder = inOrder(paymentService, notificationService);
// 결제가 먼저
inOrder.verify(paymentService).charge(any(Payment.class));
// 그 다음 알림
inOrder.verify(notificationService).notify(any(Notification.class));
}
더 이상 호출이 없음을 검증
@Test
void 검증한_호출_외에_추가_호출이_없다() {
orderService.createOrder(request);
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(any(OrderCreatedEvent.class));
// 위에서 검증한 것 외에 다른 호출이 없음을 확인
verifyNoMoreInteractions(orderRepository, eventPublisher);
}
@Test
void 특정_Mock에_아무_호출도_없었다() {
orderService.createDraftOrder(request);
// 알림 서비스는 아예 호출되지 않음
verifyNoInteractions(notificationService);
}
ArgumentCaptor — 전달된 인수를 캡처해서 검증
verify()로 "호출됐는가?"는 확인했는데, "어떤 값으로 호출됐는가?" 까지 검증하고 싶을 때 ArgumentCaptor를 씁니다.
기본 사용법
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private OrderService orderService;
@Captor // @Captor 어노테이션으로 선언
private ArgumentCaptor<OrderCreatedEvent> eventCaptor;
@Test
void 주문_생성_시_이벤트에_올바른_정보가_담긴다() {
OrderRequest request = new OrderRequest("상품A", 3, 10000);
orderService.createOrder(request);
// 이벤트 발행 시 전달된 인수를 캡처
verify(eventPublisher).publish(eventCaptor.capture());
// 캡처한 값을 상세 검증
OrderCreatedEvent captured = eventCaptor.getValue();
assertThat(captured.getProductName()).isEqualTo("상품A");
assertThat(captured.getQuantity()).isEqualTo(3);
assertThat(captured.getTotalPrice()).isEqualTo(30000);
}
}
여러 번 호출된 경우 — getAllValues()
@Test
void 대량_주문_시_각_상품별로_재고가_차감된다() {
// 3개 상품이 포함된 대량 주문
BulkOrderRequest request = new BulkOrderRequest(List.of(
new OrderItem("상품A", 2),
new OrderItem("상품B", 3),
new OrderItem("상품C", 1)
));
orderService.createBulkOrder(request);
// 여러 번 호출된 인수를 모두 캡처
ArgumentCaptor<StockDeduction> captor =
ArgumentCaptor.forClass(StockDeduction.class);
verify(stockService, times(3)).deduct(captor.capture());
// 모든 캡처 값 검증
List<StockDeduction> deductions = captor.getAllValues();
assertThat(deductions).hasSize(3);
assertThat(deductions.get(0).getProductName()).isEqualTo("상품A");
assertThat(deductions.get(0).getQuantity()).isEqualTo(2);
assertThat(deductions.get(1).getProductName()).isEqualTo("상품B");
assertThat(deductions.get(2).getQuantity()).isEqualTo(1);
}
인라인으로 캡처하기
@Captor 없이 직접 생성해서 쓸 수도 있습니다.
@Test
void 인라인으로_캡처한다() {
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
orderService.createOrder(request);
verify(orderRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(OrderStatus.PENDING);
}
BDD 스타일 — BDDMockito
BDD(Behavior-Driven Development)에서는 테스트를 Given-When-Then 구조로 작성합니다. 그런데 Mockito의 기본 API를 쓰면 "Given 섹션"에 when()이 들어가서 읽기가 어색합니다.
// 기본 Mockito — Given에 when이 들어가서 혼란스러움
@Test
void 기본_Mockito_스타일() {
// Given
when(orderRepository.findById(1L)) // "when"이 Given에?
.thenReturn(Optional.of(order));
// When
OrderResponse response = orderService.findById(1L);
// Then
verify(orderRepository).findById(1L); // verify도 어색
assertThat(response.getProductName()).isEqualTo("상품A");
}
BDDMockito를 사용하면 자연스러운 Given-When-Then이 됩니다.
import static org.mockito.BDDMockito.*;
@Test
void BDD_스타일() {
// Given — given()으로 스텁 설정
given(orderRepository.findById(1L))
.willReturn(Optional.of(order));
// When — 테스트 대상 실행
OrderResponse response = orderService.findById(1L);
// Then — then()으로 호출 검증
then(orderRepository).should().findById(1L);
assertThat(response.getProductName()).isEqualTo("상품A");
}
BDDMockito API 매핑
| 기본 Mockito | BDDMockito | 용도 |
|---|---|---|
when(mock.method()).thenReturn(value) | given(mock.method()).willReturn(value) | 반환값 스텁 |
when(mock.method()).thenThrow(ex) | given(mock.method()).willThrow(ex) | 예외 스텁 |
verify(mock).method() | then(mock).should().method() | 호출 검증 |
verify(mock, times(n)).method() | then(mock).should(times(n)).method() | 횟수 검증 |
verify(mock, never()).method() | then(mock).should(never()).method() | 미호출 검증 |
예외 테스트
@Test
void 존재하지_않는_주문_조회_시_예외가_발생한다() {
// Given
given(orderRepository.findById(999L))
.willReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> orderService.findById(999L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("주문을 찾을 수 없습니다: 999");
then(orderRepository).should().findById(999L);
}
void 메서드의 예외 스텁
@Test
void 이메일_발송_실패_시_재시도한다() {
// void 메서드에 예외를 설정할 때는 willThrow를 먼저
willThrow(new MailSendException("SMTP 오류"))
.given(emailSender).send(any(Email.class));
// 또는
willDoNothing()
.given(emailSender).send(any(Email.class));
}
@Mock vs @InjectMocks vs @Spy
이 세 가지의 차이를 명확히 아는 것이 중요합니다.
@Mock
완전한 가짜 객체 를 생성합니다. 모든 메서드가 기본값(null, 0, false)을 반환합니다.
@Mock
private OrderRepository orderRepository;
// orderRepository.findById(1L) → null (스텁 없으면)
// orderRepository.save(order) → null
@InjectMocks
테스트 대상 객체를 실제로 생성 하고, @Mock으로 만든 객체를 주입합니다.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository; // 가짜
@Mock
private EventPublisher eventPublisher; // 가짜
@InjectMocks
private OrderService orderService; // 실제 객체 (위 Mock들이 주입됨)
@Test
void 테스트_대상은_실제_로직을_실행한다() {
given(orderRepository.save(any()))
.willReturn(new Order(1L, "상품A"));
// orderService의 실제 로직이 실행됨
Order result = orderService.createOrder(request);
assertThat(result.getId()).isEqualTo(1L);
}
}
주입 순서: 생성자 → setter → 필드 리플렉션 (생성자 주입이 가장 먼저 시도됩니다)
@Spy
**실제 객체를 감싸서 **, 기본적으로 실제 메서드를 호출하되 특정 메서드만 스텁할 수 있습니다.
@Spy
private List<String> spyList = new ArrayList<>();
@Test
void Spy는_실제_메서드를_호출한다() {
spyList.add("하나");
spyList.add("둘");
// 실제 메서드가 호출되므로 size는 2
assertThat(spyList.size()).isEqualTo(2);
// 특정 메서드만 스텁
doReturn(100).when(spyList).size();
assertThat(spyList.size()).isEqualTo(100);
// 다른 메서드는 여전히 실제 동작
assertThat(spyList.get(0)).isEqualTo("하나");
}
@Spy
@InjectMocks
private OrderService orderService; // 실제 객체인데 일부 메서드만 스텁 가능
@Test
void 외부_호출만_스텁하고_나머지는_실제로_동작한다() {
// calculateDiscount만 스텁하고, 나머지 로직은 실제로 실행
doReturn(Money.of(1000))
.when(orderService).calculateDiscount(any());
Order result = orderService.createOrder(request);
// createOrder의 나머지 로직은 실제로 실행됨
}
doReturn/doThrow vs when/thenReturn
대부분의 경우 when().thenReturn()을 쓰면 됩니다. 하지만 ** 두 가지 경우 **에는 doReturn().when()을 써야 합니다.
1. void 메서드 스텁
// void 메서드는 when() 안에 넣을 수 없음
// when(emailSender.send(email)).thenThrow(...) ← 컴파일 에러!
// doThrow를 사용
doThrow(new MailSendException("실패"))
.when(emailSender).send(any(Email.class));
doNothing()
.when(emailSender).send(any(Email.class));
2. @Spy에서 실제 호출 방지
@Spy
private OrderService orderService;
// when(orderService.calculate(order)).thenReturn(1000);
// ↑ 이렇게 하면 calculate()가 실제로 한 번 호출됨!
// doReturn으로 실제 호출 없이 스텁
doReturn(1000).when(orderService).calculate(order);
when().thenReturn()은 when() 안의 메서드를 실제로 한 번 호출합니다. Mock 객체라면 상관없지만(기본값 반환), Spy 객체에서는 실제 로직이 실행되어 사이드 이펙트가 발생할 수 있습니다.
lenient() — 불필요한 stubbing 경고 처리
Mockito는 기본적으로 ** 사용되지 않는 stubbing을 감지해서 경고 **합니다. @ExtendWith(MockitoExtension.class)를 사용하면 이 경고가 테스트 실패로 이어집니다.
@Test
void 불필요한_stubbing이_있으면_실패한다() {
// 이 스텁이 테스트에서 사용되지 않으면 UnnecessaryStubbingException 발생
given(orderRepository.findById(1L))
.willReturn(Optional.of(order));
// findById를 호출하지 않는 다른 로직만 테스트...
orderService.deleteAll();
}
해결 방법:
// 방법 1: 해당 스텁만 lenient로 설정
lenient().when(orderRepository.findById(1L))
.thenReturn(Optional.of(order));
// 방법 2: 클래스 전체를 lenient로 (권장하지 않음)
@MockitoSettings(strictness = Strictness.LENIENT)
class OrderServiceTest { /* ... */ }
불필요한 stubbing 경고는 대부분 테스트가 너무 많은 것을 준비하고 있다는 신호 입니다. lenient를 남발하기보다 테스트를 더 작게 쪼개는 것이 좋습니다.
실무 모킹 원칙 — "외부에만 모킹"
모킹에서 가장 중요한 원칙은 "테스트하려는 것의 외부에만 Mock을 적용한다" 입니다.
테스트 대상: OrderService
├── 내부 로직: 할인 계산, 유효성 검증 → Mock하지 않음 (실제 실행)
└── 외부 의존: OrderRepository, EventPublisher → Mock 적용
// 좋은 예: 외부 의존만 Mock
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository; // 외부 — Mock
@Mock private EventPublisher eventPublisher; // 외부 — Mock
@InjectMocks private OrderService orderService; // 테스트 대상 — 실제
@Test
void 주문_생성_시_할인이_적용된다() {
given(orderRepository.save(any())).willAnswer(invocation -> {
Order order = invocation.getArgument(0);
// save 결과로 ID가 부여된 객체 반환
return new Order(1L, order.getProductName(),
order.getQuantity(), order.getDiscountedPrice());
});
// OrderService 내부의 할인 계산 로직이 실제로 실행됨
Order result = orderService.createOrder(
new OrderRequest("상품A", 2, 10000)
);
assertThat(result.getDiscountedPrice()).isEqualTo(9000);
}
}
// 나쁜 예: 내부 로직까지 Mock → 테스트가 구현에 결합됨
@Test
void 이렇게_하지_마세요() {
// 내부 할인 계산까지 Mock하면 로직 변경 시 테스트도 깨짐
given(discountCalculator.calculate(any(), any()))
.willReturn(Money.of(9000));
// 이 테스트는 "할인이 올바르게 계산되는가?"를 전혀 검증하지 않음
}
추가 실무 팁:
- Mock이 3개 이상 필요하면, 테스트 대상 클래스의 책임이 너무 많은 것은 아닌지 의심해보세요
verify()로 ** 내부 구현의 호출 순서 **까지 검증하면 리팩토링할 때마다 테스트가 깨집니다 — 결과(행위)를 검증하세요any()보다 ** 구체적인 매처 **(eq(),argThat())를 쓰면 버그를 더 잘 잡습니다
// any() 대신 구체적인 매처 사용
@Test
void 구체적_매처로_의도를_명확히_한다() {
orderService.createOrder(new OrderRequest("상품A", 2, 10000));
// any()보다 argThat으로 조건을 명시
verify(orderRepository).save(argThat(order ->
order.getProductName().equals("상품A") &&
order.getQuantity() == 2
));
}
정리
- Mockito 5.x부터 inline mock maker가 기본 이라 final 클래스 모킹이 바로 됩니다
verify()로 호출 횟수(times,never,atLeast)와 순서(InOrder)를 검증할 수 있습니다ArgumentCaptor로 메서드에 전달된 인수를 캡처해서 상세하게 검증 할 수 있습니다- BDDMockito의
given/then을 쓰면 Given-When-Then 구조가 자연스러워 집니다 doReturn().when()은 void 메서드와 Spy 객체 에서 필요합니다- 모킹의 핵심 원칙: 테스트 대상의 "외부"에만 Mock을 적용 하세요