← all posts
DEV 2026.05.02 · 13 min read Intermediate

Mock은 하나가 아니다 — Test Double의 다섯 얼굴

Dummy부터 Fake까지 다섯 종류의 Test Double이 왜 존재하는지, 잘못된 선택이 어떤 버그를 숨기는지, 그리고 verify()를 언제 써야 하는지 추적한다.


mock()을 쓰면 되는 거 아닌가? 많은 팀이 이렇게 생각하다가 “저장 후 조회가 왜 안 되지?”라거나 “이벤트가 발행됐는지 어떻게 확인하지?”라는 벽에 부딪힌다. Test Double에는 Dummy, Stub, Spy, Mock, Fake 다섯 종류가 있고, 각각 목적이 다르다. 같은 mock()으로 만들더라도 어떻게 쓰느냐에 따라 성격이 달라진다. 이 다섯 가지를 구분하지 못하면 테스트가 버그를 잡는 것이 아니라 숨기게 된다.

Test Double이 필요한 이유

OrderService.place()InventoryClient, OrderRepository, EventPublisher, AuditLogger 네 개의 의존성을 가진다고 하자. 이 네 개를 모두 같은 방식으로 교체하는 것이 맞을까?

  • inventoryClient: 재고 예약이 성공했는가 — 값이 필요하다
  • orderRepository: 저장된 주문을 나중에 조회할 수 있어야 한다
  • eventPublisher: 이벤트가 발행됐는가 — 호출 여부가 관심사다
  • auditLogger: 이 테스트에서는 아무 역할도 하지 않아도 된다

네 개에 같은 Mock을 쓰면 테스트가 필요 이상으로 복잡해지거나, 검증해야 할 것을 검증하지 못하게 된다. 의존성마다 역할이 다르고, 역할에 맞는 Test Double이 따로 있다.

다섯 종류와 선택 기준

Gerard Meszaros가 xUnit Test Patterns에서 정의한 분류는 Test Double의 목적에 따라 나뉜다.

Dummy는 파라미터를 채워야 하지만 테스트 결과에 아무 영향도 주지 않는 객체다. AuditLogger가 그 자리다. mock()으로 만들어도 되지만, when()verify()도 없다.

Stub은 미리 정해진 값을 반환한다. InventoryClient가 항상 재고 있음을 반환해야 한다면 (itemId, qty) -> true처럼 람다로 만들거나 when().thenReturn()으로 고정한다. Stub의 관심사는 “어떤 값을 반환하는가”이고, 몇 번 호출됐는지는 신경 쓰지 않는다.

Mock은 기대하는 호출을 사전에 또는 사후에 검증한다. EventPublisherOrderPlaced 이벤트를 발행했는지 확인하려면 verify(eventPublisher).publish(argThat(e -> e instanceof OrderPlaced))가 필요하다. Mock의 관심사는 “어떻게 호출됐는가”다.

Spy는 실제 객체를 감싸고 호출 기록을 남긴다. 설정하지 않은 메서드는 실제 구현이 실행된다. 레거시 코드에서 일부 메서드만 임시로 교체할 때나 추상 클래스의 공통 로직을 테스트할 때 유용하다. 단, 새로 작성하는 코드에서 Spy가 반복적으로 필요하다면 설계를 먼저 의심해야 한다.

Fake는 실제로 동작하는 간소화된 구현체다. InMemoryOrderRepositoryHashMap으로 저장하고 조회한다. 외부 의존 없이 “저장 → 조회”가 실제로 연결된다.

"테스트에서 이 의존성이 하는 역할은 무엇인가?"

아무 역할 없음            → Dummy
고정 값 제공              → Stub
호출 기록 + 실제 동작     → Spy (설계 의심 먼저)
올바른 메시지를 보냈는지  → Mock
실제처럼 저장·조회        → Fake

잘못된 선택이 숨기는 버그

OrderRepository에 Mock을 쓰면 어떤 일이 생기는지 보자.

// ❌ save()와 findByUserId()가 연결되지 않는다
OrderRepository mockRepo = mock(OrderRepository.class);
when(mockRepo.save(any())).thenReturn(savedOrder);
when(mockRepo.findByUserId(vipUser.id())).thenReturn(List.of(savedOrder));

Stub을 손으로 맞춰야 하고, findByUserId Stub을 빠뜨리면 null이 반환된다. “저장 후 조회”라는 도메인 흐름이 테스트에서 실제로 동작하지 않는다.

// ✅ Fake — save()와 findByUserId()가 실제로 연결된다
InMemoryOrderRepository fakeRepo = new InMemoryOrderRepository();
Order result = service.place(cart, vipUser);
assertThat(fakeRepo.findById(result.id())).isPresent();

반대 방향의 실수도 있다. 이벤트 발행을 Stub으로 “검증”하려는 경우다.

// ❌ void 메서드에 when()만 설정하고 verify()를 빠뜨리면
//    eventPublisher.publish()가 삭제돼도 이 테스트는 통과한다
EventPublisher stubPublisher = mock(EventPublisher.class);
orderService.place(cart, user);
// 검증 없음 — 이벤트가 발행됐는지 아무도 확인하지 않는다

이벤트처럼 반환값도 상태도 남기지 않는 부수효과는 Mock + verify()가 유일한 선택이다.

상태 검증과 행동 검증

Stub/Fake와 Mock의 차이는 “무엇을 검증하는가”로 귀결된다.

상태 검증은 결과가 올바른지 assertThat으로 확인한다. 내부 구현이 바뀌어도 결과가 같으면 통과한다. 리팩터링 내성이 높다.

행동 검증은 협력 객체가 어떻게 호출됐는지 verify()로 확인한다. 내부 구현이 바뀌면 깨질 수 있다. 기능은 동일한데 테스트가 깨지는 것은 행동 검증의 리팩터링 취약성 때문이다.

// 리팩터링 후 EmailComposer를 거치도록 변경했다면
// 상태 검증 테스트 (Fake EmailSender): 여전히 올바른 주소로 발송되면 통과 ✅
// 행동 검증 테스트 (Mock EmailSender): send() 시그니처가 바뀌면 실패 ❌

선택 기준은 단순하다.

반환값이 있거나 상태가 남는다 → 상태 검증 (Stub/Fake + assertThat)
부수효과만 있고 흔적이 없다  → 행동 검증 (Mock + verify)

verify()를 쓰면 안 되는 경우

verify()를 과잉 사용하면 테스트가 구현의 그림자가 된다.

// ❌ 결과로 확인할 수 있는 것을 verify로 확인
verify(discountPolicy).calculate(vipUser); // order.totalPrice()가 맞으면 자명하다
verify(orderRepository).save(any());       // Fake로 상태 검증하면 충분하다

discountPolicy.calculate()가 호출됐는지 확인하는 것이 목적인가, 할인이 올바르게 적용됐는지 확인하는 것이 목적인가? 후자라면 assertThat(order.totalPrice()).isEqualTo(18_000)으로 충분하다.

verify()가 필요한 곳은 정확히 하나다 — 반환값도 없고 상태도 남지 않는 부수효과. 이메일 발송, 이벤트 발행, 외부 API 전송이 그 자리다. 그리고 “호출되지 않아야 한다”는 것을 확인할 때 verify(mock, never())verifyNoInteractions()를 쓴다.

트레이드오프

Mock은 구현되지 않은 협력자를 TDD 탐색 단계에서 빠르게 쓸 수 있고, Fake 구현 비용이 과도할 때 현실적인 대안이 된다. 그러나 과도한 verify()는 리팩터링할 때마다 테스트를 깨뜨린다. “이 verify가 없으면 잡지 못하는 버그가 있는가?”라는 질문이 없다면 제거하거나 assertThat으로 교체하는 것이 낫다.

정리

  • Test Double은 5종류다: Dummy(역할 없음), Stub(값 제공), Spy(실제 + 기록), Mock(호출 검증), Fake(실제처럼 동작).
  • OrderRepository에는 Fake가, EventPublisher에는 Mock이 맞다. 같은 자리에 같은 도구를 쓰지 않는다.
  • 상태가 남는 곳은 assertThat으로, 흔적이 없는 부수효과는 verify()로 검증한다.
  • verify()는 부수효과 전용이다. 결과로 확인할 수 있는 것에 verify()를 쓰면 리팩터링이 테스트를 깨뜨린다.

다음 글에서는 @InjectMocks의 조용한 함정과 ArgumentCaptor로 복잡한 이벤트를 정밀하게 검증하는 방법을 추적한다.