Mock 객체가 "어떤 인수로" 호출되었는지까지 검증하고 싶다면 어떻게 해야 할까요?

when().thenReturn()으로 반환값을 지정하는 건 Mockito의 기본입니다. 하지만 실무에서는 호출 횟수를 검증하거나, 전달된 인수를 캡처해서 상세 검증 해야 하는 경우가 훨씬 많습니다. 공부하다 보니 verifyArgumentCaptor를 능숙하게 쓸 수 있느냐가 테스트 품질에 직접적인 차이를 만들더라고요.

Mockito 5.x 핵심 변경점

Mockito 5.x에서 가장 큰 변화는 inline mock maker가 기본 이 된 것입니다.

이전(4.x 이하):

  • 기본이 subclass mock maker → final 클래스 모킹 불가
  • final 클래스를 모킹하려면 mockito-extensions 파일을 추가해야 했음

이후(5.x):

  • inline mock maker가 기본 → final 클래스도 그냥 모킹됨
  • 별도 설정 파일 불필요
JAVA
// 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()는 ** 모킹된 객체의 메서드가 호출되었는지 검증 **합니다. 단순히 "호출됐는가?"를 넘어서 다양한 조건을 걸 수 있습니다.

호출 횟수 검증

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

호출 순서 검증

여러 메서드가 ** 특정 순서로 호출 **되었는지도 검증할 수 있습니다.

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

더 이상 호출이 없음을 검증

JAVA
@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를 씁니다.

기본 사용법

JAVA
@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()

JAVA
@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 없이 직접 생성해서 쓸 수도 있습니다.

JAVA
@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()이 들어가서 읽기가 어색합니다.

JAVA
// 기본 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이 됩니다.

JAVA
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 매핑

기본 MockitoBDDMockito용도
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()미호출 검증

예외 테스트

JAVA
@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 메서드의 예외 스텁

JAVA
@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)을 반환합니다.

JAVA
@Mock
private OrderRepository orderRepository;
// orderRepository.findById(1L) → null (스텁 없으면)
// orderRepository.save(order) → null

@InjectMocks

테스트 대상 객체를 실제로 생성 하고, @Mock으로 만든 객체를 주입합니다.

JAVA
@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

**실제 객체를 감싸서 **, 기본적으로 실제 메서드를 호출하되 특정 메서드만 스텁할 수 있습니다.

JAVA
@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("하나");
}
JAVA
@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 메서드 스텁

JAVA
// 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에서 실제 호출 방지

JAVA
@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)를 사용하면 이 경고가 테스트 실패로 이어집니다.

JAVA
@Test
void 불필요한_stubbing이_있으면_실패한다() {
    // 이 스텁이 테스트에서 사용되지 않으면 UnnecessaryStubbingException 발생
    given(orderRepository.findById(1L))
        .willReturn(Optional.of(order));

    // findById를 호출하지 않는 다른 로직만 테스트...
    orderService.deleteAll();
}

해결 방법:

JAVA
// 방법 1: 해당 스텁만 lenient로 설정
lenient().when(orderRepository.findById(1L))
    .thenReturn(Optional.of(order));

// 방법 2: 클래스 전체를 lenient로 (권장하지 않음)
@MockitoSettings(strictness = Strictness.LENIENT)
class OrderServiceTest { /* ... */ }

불필요한 stubbing 경고는 대부분 테스트가 너무 많은 것을 준비하고 있다는 신호 입니다. lenient를 남발하기보다 테스트를 더 작게 쪼개는 것이 좋습니다.

실무 모킹 원칙 — "외부에만 모킹"

모킹에서 가장 중요한 원칙은 "테스트하려는 것의 외부에만 Mock을 적용한다" 입니다.

PLAINTEXT
테스트 대상: OrderService
├── 내부 로직: 할인 계산, 유효성 검증 → Mock하지 않음 (실제 실행)
└── 외부 의존: OrderRepository, EventPublisher → Mock 적용
JAVA
// 좋은 예: 외부 의존만 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);
    }
}
JAVA
// 나쁜 예: 내부 로직까지 Mock → 테스트가 구현에 결합됨
@Test
void 이렇게_하지_마세요() {
    // 내부 할인 계산까지 Mock하면 로직 변경 시 테스트도 깨짐
    given(discountCalculator.calculate(any(), any()))
        .willReturn(Money.of(9000));

    // 이 테스트는 "할인이 올바르게 계산되는가?"를 전혀 검증하지 않음
}

추가 실무 팁:

  • Mock이 3개 이상 필요하면, 테스트 대상 클래스의 책임이 너무 많은 것은 아닌지 의심해보세요
  • verify()로 ** 내부 구현의 호출 순서 **까지 검증하면 리팩토링할 때마다 테스트가 깨집니다 — 결과(행위)를 검증하세요
  • any()보다 ** 구체적인 매처 **(eq(), argThat())를 쓰면 버그를 더 잘 잡습니다
JAVA
// 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을 적용 하세요
댓글 로딩 중...