테스트 더블 전략: Mock vs Fake vs Stub

6
25
공유

테스트 더블 유형 개요

유형 목적 사용 시점 예시
Stub 고정된 값 반환 입력에 대한 출력만 필요 when().thenReturn()
Mock 호출 검증 상호작용 검증 필요 verify()
Fake 실제 동작하는 가짜 구현 복잡한 의존성 대체 FakeRepository
Spy 실제 객체 + 부분 변경 일부만 대체 필요 @Spy

  1. 언제 무엇을 사용해야 하는가

의사결정 플로우차트

┌─────────────────────────────────────────────────────────────────┐
│ 테스트 더블 선택 가이드 │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────┐
│ 외부 시스템인가? │
│ (API, DB, MQ) │
└────────┬────────┘

┌──────────────┴──────────────┐
│ Yes │ No
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 실제 동작이 │ │ 호출 검증이 │
│ 필요한가? │ │ 필요한가? │
└────────┬────────┘ └────────┬────────┘
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ Yes │ No │ Yes │ No
▼ ▼ ▼ ▼
┌───────┐ ┌───────────┐ ┌───────┐ ┌───────────┐
│ Fake │ │Testcontainer│ │ Mock │ │ Stub │
│ │ │ or Fake │ │ │ │ │
└───────┘ └───────────┘ └───────┘ └───────────┘

상황별 권장 테스트 더블

의존성 단위 테스트 통합 테스트 E2E 테스트
Repository Mock/Fake 실제 (Testcontainers) 실제
외부 API Stub/Mock WireMock/Fake 실제 or Mock
메시지 큐 Mock Embedded/Fake Testcontainers
캐시 (Redis) Mock Fake/Embedded Testcontainers
이메일 서비스 Mock Fake Fake
결제 서비스 Mock Fake Sandbox API

  1. Stub 사용법

언제 사용하는가

  • 단순히 값을 반환받기만 하면 될 때
  • 호출 여부나 횟수 검증이 필요 없을 때
  • 테스트 대상의 입력 조건을 설정할 때

예시

@ServiceTest
class OrderServiceTest {

  @Mock
  UserRepository userRepository;

  @Mock
  ProductRepository productRepository;

  @InjectMocks
  OrderService orderService;

  @Test
  void 주문_금액_계산() {
      // given - Stub: 값만 반환하면 됨
      User user = UserFixture.user();
      Product product = ProductFixture.builder()
          .price(10000)
          .build();

      when(userRepository.findById(1L))
          .thenReturn(Optional.of(user));
      when(productRepository.findById(100L))
          .thenReturn(Optional.of(product));

      // when
      OrderRequest request = new OrderRequest(1L, 100L, 3);
      Order order = orderService.createOrder(request);

      // then - 결과만 검증, 호출 검증 없음
      assertThat(order.getTotalAmount()).isEqualTo(30000);
  }

}

Stub 헬퍼 (프레임워크 제공)

public class StubHelper {

  /**
   * Repository findById Stub 설정
   */
  public static <T, ID> void stubFindById(
          JpaRepository<T, ID> repository,
          ID id,
          T entity) {
      when(repository.findById(id))
          .thenReturn(Optional.ofNullable(entity));
  }

  /**
   * Repository findAll Stub 설정
   */
  public static <T> void stubFindAll(
          JpaRepository<T, ?> repository,
          List<T> entities) {
      when(repository.findAll()).thenReturn(entities);
  }

  /**
   * 페이징 결과 Stub 설정
   */
  public static <T> void stubFindAllPaged(
          JpaRepository<T, ?> repository,
          List<T> content,
          Pageable pageable) {
      when(repository.findAll(pageable))
          .thenReturn(new PageImpl<>(content, pageable, content.size()));
  }

}

// 사용 예시
@Test
void 사용자_목록_조회() {
// given
List users = UserFixture.users(5);
StubHelper.stubFindAll(userRepository, users);

  // when
  List<User> result = userService.findAll();

  // then
  assertThat(result).hasSize(5);

}


  1. Mock 사용법

언제 사용하는가

  • 메서드가 호출되었는지 검증해야 할 때
  • 호출 횟수가 중요할 때
  • 호출 순서가 중요할 때
  • 부수 효과(side effect)를 검증해야 할 때

예시

@ServiceTest
class OrderServiceTest {

  @Mock
  OrderRepository orderRepository;

  @Mock
  NotificationService notificationService;

  @Mock
  InventoryService inventoryService;

  @InjectMocks
  OrderService orderService;

  @Test
  void 주문_생성시_알림_발송_검증() {
      // given
      OrderRequest request = OrderRequestFixture.request();

      when(orderRepository.save(any()))
          .thenAnswer(inv -> {
              Order order = inv.getArgument(0);
              ReflectionTestUtils.setField(order, "id", 1L);
              return order;
          });

      // when
      orderService.createOrder(request);

      // then - Mock: 호출 검증이 핵심
      verify(notificationService).sendOrderConfirmation(eq(1L));
      verify(inventoryService).decreaseStock(any());
  }

  @Test
  void 주문_취소시_재고_복원_검증() {
      // given
      Order order = OrderFixture.order();
      when(orderRepository.findById(1L)).thenReturn(Optional.of(order));

      // when
      orderService.cancelOrder(1L);

      // then - 호출 순서까지 검증
      InOrder inOrder = inOrder(orderRepository, inventoryService);
      inOrder.verify(orderRepository).findById(1L);
      inOrder.verify(inventoryService).increaseStock(any());
      inOrder.verify(orderRepository).save(any());
  }

  @Test
  void 알림_발송_실패해도_주문은_성공() {
      // given
      OrderRequest request = OrderRequestFixture.request();

      when(orderRepository.save(any())).thenAnswer(inv -> {
          Order order = inv.getArgument(0);
          ReflectionTestUtils.setField(order, "id", 1L);
          return order;
      });

      // 알림 실패 시뮬레이션
      doThrow(new NotificationException("Failed"))
          .when(notificationService).sendOrderConfirmation(any());

      // when
      Order order = orderService.createOrder(request);

      // then - 주문은 성공해야 함
      assertThat(order.getId()).isEqualTo(1L);
      verify(notificationService).sendOrderConfirmation(any());  // 호출은 됨
  }

}

Mock 검증 헬퍼 (프레임워크 제공)

public class MockVerifyHelper {

  /**
   * 정확히 N번 호출 검증
   */
  public static <T> void verifyCalledTimes(T mock, int times,
          Consumer<T> methodCall) {
      methodCall.accept(verify(mock, times(times)));
  }

  /**
   * 호출 안됨 검증
   */
  public static <T> void verifyNeverCalled(T mock,
          Consumer<T> methodCall) {
      methodCall.accept(verify(mock, never()));
  }

  /**
   * 모든 상호작용 검증 완료 확인
   */
  public static void verifyNoMoreInteractionsOn(Object... mocks) {
      verifyNoMoreInteractions(mocks);
  }

}


  1. Fake 사용법

언제 사용하는가

  • Mock으로 표현하기 복잡한 로직이 있을 때
  • 상태 유지가 필요할 때 (저장 → 조회)
  • 실제와 유사한 동작이 필요할 때
  • 여러 테스트에서 재사용할 때

Fake Repository 예시

/**

  • 인메모리 Fake Repository - 프레임워크 제공 기반 클래스
    */
    public abstract class FakeRepository<T, ID> {

    protected final Map<ID, T> store = new ConcurrentHashMap<>();
    protected final AtomicLong idGenerator = new AtomicLong(1);

    protected abstract ID getId(T entity);
    protected abstract void setId(T entity, ID id);

    public T save(T entity) {
    ID id = getId(entity);
    if (id == null) {
    id = generateId();
    setId(entity, id);
    }
    store.put(id, entity);
    return entity;
    }

    public Optional findById(ID id) {
    return Optional.ofNullable(store.get(id));
    }

    public List findAll() {
    return new ArrayList<>(store.values());
    }

    public void deleteById(ID id) {
    store.remove(id);
    }

    public void deleteAll() {
    store.clear();
    }

    public long count() {
    return store.size();
    }

    @SuppressWarnings("unchecked")
    protected ID generateId() {
    return (ID) Long.valueOf(idGenerator.getAndIncrement());
    }

}

실제 Fake 구현

/**

  • User Fake Repository
    */
    public class FakeUserRepository extends FakeRepository<User, Long>
    implements UserRepository {
    @Override
    protected Long getId(User user) {
    return user.getId();
    }

    @Override
    protected void setId(User user, Long id) {
    ReflectionTestUtils.setField(user, "id", id);
    }

    // 커스텀 쿼리 메서드 구현
    @Override
    public Optional findByEmail(String email) {
    return store.values().stream()
    .filter(user -> email.equals(user.getEmail()))
    .findFirst();
    }

    @Override
    public List findByStatus(Status status) {
    return store.values().stream()
    .filter(user -> status.equals(user.getStatus()))
    .collect(Collectors.toList());
    }

    @Override
    public boolean existsByEmail(String email) {
    return findByEmail(email).isPresent();
    }

}

Fake 외부 서비스 예시

/**

  • 결제 서비스 Fake
    */
    public class FakePaymentService implements PaymentService {

    private final Map<String, Payment> payments = new ConcurrentHashMap<>();
    private final Set failingCards = new HashSet<>();

    @Override
    public PaymentResult process(PaymentRequest request) {
    // 실패 시뮬레이션
    if (failingCards.contains(request.getCardNumber())) {
    return PaymentResult.failed("CARD_DECLINED", "카드 거절됨");
    }

    // 성공 케이스
    String transactionId = UUID.randomUUID().toString();
    Payment payment = new Payment(
    transactionId,
    request.getAmount(),
    PaymentStatus.COMPLETED
    );
    payments.put(transactionId, payment);

    return PaymentResult.success(transactionId);
    }

    @Override
    public Payment getPayment(String transactionId) {
    return payments.get(transactionId);
    }

    @Override
    public RefundResult refund(String transactionId) {
    Payment payment = payments.get(transactionId);
    if (payment == null) {
    return RefundResult.failed("NOT_FOUND");
    }
    payment.setStatus(PaymentStatus.REFUNDED);
    return RefundResult.success();
    }

    // ========== 테스트 헬퍼 메서드 ==========

    public void simulateCardFailure(String cardNumber) {
    failingCards.add(cardNumber);
    }

    public void reset() {
    payments.clear();
    failingCards.clear();
    }

    public int getPaymentCount() {
    return payments.size();
    }

}

Fake 사용 예시

@ServiceTest
class PaymentServiceTest {

  FakePaymentService fakePaymentService = new FakePaymentService();
  FakeOrderRepository fakeOrderRepository = new FakeOrderRepository();

  OrderPaymentService orderPaymentService;

  @BeforeEach
  void setUp() {
      fakePaymentService.reset();
      fakeOrderRepository.deleteAll();

      orderPaymentService = new OrderPaymentService(
          fakeOrderRepository,
          fakePaymentService
      );
  }

  @Test
  void 결제_성공_후_주문_상태_변경() {
      // given
      Order order = fakeOrderRepository.save(OrderFixture.pendingOrder());
      PaymentRequest request = new PaymentRequest(
          order.getId(),
          "4111111111111111",
          order.getTotalAmount()
      );

      // when
      PaymentResult result = orderPaymentService.pay(request);

      // then
      assertThat(result.isSuccess()).isTrue();

      Order updatedOrder = fakeOrderRepository.findById(order.getId()).orElseThrow();
      assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID);
      assertThat(updatedOrder.getTransactionId()).isEqualTo(result.getTransactionId());
  }

  @Test
  void 카드_거절시_주문_상태_유지() {
      // given
      Order order = fakeOrderRepository.save(OrderFixture.pendingOrder());
      fakePaymentService.simulateCardFailure("4000000000000002");  // 실패 시뮬레이션

      PaymentRequest request = new PaymentRequest(
          order.getId(),
          "4000000000000002",
          order.getTotalAmount()
      );

      // when
      PaymentResult result = orderPaymentService.pay(request);

      // then
      assertThat(result.isSuccess()).isFalse();
      assertThat(result.getErrorCode()).isEqualTo("CARD_DECLINED");

      Order unchangedOrder = fakeOrderRepository.findById(order.getId()).orElseThrow();
      assertThat(unchangedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
  }

}


  1. Spy 사용법

언제 사용하는가

  • 실제 객체를 사용하되, 일부 메서드만 대체할 때
  • 실제 로직을 타면서 특정 부분만 검증할 때
  • 레거시 코드 테스트 시 부분적 Mocking

예시

@ServiceTest
class NotificationServiceTest {

  @Spy
  EmailClient emailClient = new RealEmailClient();  // 실제 객체

  @InjectMocks
  NotificationService notificationService;

  @Test
  void 이메일_발송_실패시_재시도() {
      // given - 첫 번째 호출만 실패하도록 설정
      doThrow(new EmailException("Temporary failure"))
          .doCallRealMethod()  // 두 번째부터는 실제 메서드 호출
          .when(emailClient).send(any());

      // when
      notificationService.sendWithRetry("test@example.com", "Hello");

      // then - 2번 호출됨 (재시도 1회)
      verify(emailClient, times(2)).send(any());
  }

  @Test
  void 실제_이메일_포맷_검증() {
      // given - 실제 포맷팅 로직 사용

      // when
      notificationService.sendOrderConfirmation(1L, "user@example.com");

      // then - 실제 포맷팅된 내용 검증
      ArgumentCaptor<EmailMessage> captor = ArgumentCaptor.forClass(EmailMessage.class);
      verify(emailClient).send(captor.capture());

      EmailMessage sent = captor.getValue();
      assertThat(sent.getSubject()).contains("주문 확인");
      assertThat(sent.getBody()).contains("주문번호: 1");
  }

}

Spy 주의사항

// ⚠️ 주의: Spy는 실제 메서드를 호출함

@Spy
OrderService orderService; // 실제 OrderService

@Test
void spy_주의사항() {
// ❌ 위험: 실제 메서드가 먼저 호출됨
when(orderService.calculate(any())).thenReturn(1000);

  // ✅ 안전: doReturn 사용
  doReturn(1000).when(orderService).calculate(any());

}


  1. 프레임워크에서 제공하는 가이드라인

테스트 더블 선택 가이드 (어노테이션)

/**

  • 테스트 더블 사용 의도를 명시하는 어노테이션
    */
    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface TestDouble {

    Type type();

    String reason() default "";

    enum Type {
    STUB, // 값 반환용
    MOCK, // 호출 검증용
    FAKE, // 실제 동작 대체
    SPY // 부분 대체
    }

}

@ServiceTest
class OrderServiceTest {

  @Mock
  @TestDouble(type = STUB, reason = "주문 조회 결과만 필요")
  OrderRepository orderRepository;

  @Mock
  @TestDouble(type = MOCK, reason = "알림 발송 호출 검증 필요")
  NotificationService notificationService;

  @TestDouble(type = FAKE, reason = "결제 상태 관리 필요")
  FakePaymentService paymentService = new FakePaymentService();

}

테스트 더블 문서화 템플릿

/**

  • 테스트 더블 선택 가이드
  • ┌─────────────────────────────────────────────────────────────┐
  • │ 질문 │ Yes → 선택 │
  • ├─────────────────────────────────────────────────────────────┤
  • │ 호출 검증이 필요한가? │ Yes → Mock │
  • │ 상태 유지가 필요한가? │ Yes → Fake │
  • │ 값만 반환하면 되는가? │ Yes → Stub │
  • │ 실제 객체의 일부만 대체? │ Yes → Spy │
  • │ 복잡한 외부 시스템? │ Yes → Fake or Container │
  • └─────────────────────────────────────────────────────────────┘
    */

  1. 안티패턴 가이드

❌ 피해야 할 패턴

// ❌ 안티패턴 1: 과도한 Mock
@Test
void 과도한_mock() {
when(mock1.method()).thenReturn(value1);
when(mock2.method()).thenReturn(value2);
when(mock3.method()).thenReturn(value3);
when(mock4.method()).thenReturn(value4);
when(mock5.method()).thenReturn(value5);
// ... Mock이 5개 이상이면 설계 재검토 필요
}

// ❌ 안티패턴 2: 구현 세부사항 검증
@Test
void 구현_세부사항_검증() {
orderService.createOrder(request);

  // 내부 private 메서드 호출 순서까지 검증 - 리팩토링에 취약
  verify(orderRepository).save(any());
  verify(orderRepository).flush();
  verify(eventPublisher).publish(any());
  verifyNoMoreInteractions(orderRepository);  // 과도한 검증

}

// ❌ 안티패턴 3: Mock으로 복잡한 로직 시뮬레이션
@Test
void mock으로_복잡한_로직() {
when(repository.findByStatus(ACTIVE))
.thenReturn(list1);
when(repository.findByStatus(INACTIVE))
.thenReturn(list2);
when(repository.findByStatusAndDate(any(), any()))
.thenAnswer(inv -> {
// 복잡한 필터링 로직...
// → Fake 사용이 더 적합
});
}

✅ 권장 패턴

// ✅ 권장 1: 적절한 수의 테스트 더블
@Test
void 적절한_테스트_더블() {
// Mock 2-3개 이내로 유지
// 더 필요하면 Fake 사용 또는 설계 재검토
}

// ✅ 권장 2: 행동(Behavior) 검증
@Test
void 행동_검증() {
orderService.createOrder(request);

  // 핵심 행동만 검증
  verify(notificationService).sendOrderConfirmation(any());
  // 호출 순서, 내부 구현은 검증하지 않음

}

// ✅ 권장 3: 복잡한 의존성은 Fake
@Test
void fake_사용() {
FakeInventoryService inventory = new FakeInventoryService();
inventory.addStock(productId, 100);

  // Fake는 실제처럼 동작하므로 테스트가 명확
  orderService.createOrder(request);

  assertThat(inventory.getStock(productId)).isEqualTo(97);

}


  1. 문서에 추가할 내용 제안

5.9 테스트 더블 전략

테스트 더블 유형

유형 목적 사용 시점
Stub 값 반환 입력 조건 설정
Mock 호출 검증 상호작용 검증 필요
Fake 실제 동작 복잡한 의존성, 상태 유지
Spy 부분 대체 일부만 변경 필요

선택 가이드

  • Mock 3개 이상 필요 → Fake 또는 설계 재검토
  • 상태 유지 필요 → Fake
  • 호출 검증만 필요 → Mock
  • 값만 필요 → Stub

프레임워크 제공

  • FakeRepository<T, ID> 기반 클래스
  • StubHelper, MockVerifyHelper 유틸리티
  • @TestDouble 문서화 어노테이션

안티패턴

  • 과도한 Mock (5개 이상)
  • 구현 세부사항 검증
  • Mock으로 복잡한 로직 시뮬레이션

댓글