테스트 더블 전략: Mock vs Fake vs Stub
테스트 더블 유형 개요
| 유형 | 목적 | 사용 시점 | 예시 |
|---|---|---|---|
| Stub | 고정된 값 반환 | 입력에 대한 출력만 필요 | when().thenReturn() |
| Mock | 호출 검증 | 상호작용 검증 필요 | verify() |
| Fake | 실제 동작하는 가짜 구현 | 복잡한 의존성 대체 | FakeRepository |
| Spy | 실제 객체 + 부분 변경 | 일부만 대체 필요 | @Spy |
- 언제 무엇을 사용해야 하는가
의사결정 플로우차트
┌─────────────────────────────────────────────────────────────────┐
│ 테스트 더블 선택 가이드 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 외부 시스템인가? │
│ (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 |
- 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
StubHelper.stubFindAll(userRepository, users);
// when
List<User> result = userService.findAll();
// then
assertThat(result).hasSize(5);}
- 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);
}}
- 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 OptionalfindByEmail(String email) {
return store.values().stream()
.filter(user -> email.equals(user.getEmail()))
.findFirst();
}@Override
public ListfindByStatus(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 SetfailingCards = 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);
}}
- 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());}
- 프레임워크에서 제공하는 가이드라인
테스트 더블 선택 가이드 (어노테이션)
/**
테스트 더블 사용 의도를 명시하는 어노테이션
*/
@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: 과도한 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);}
- 문서에 추가할 내용 제안
5.9 테스트 더블 전략
테스트 더블 유형
| 유형 | 목적 | 사용 시점 |
|---|---|---|
| Stub | 값 반환 | 입력 조건 설정 |
| Mock | 호출 검증 | 상호작용 검증 필요 |
| Fake | 실제 동작 | 복잡한 의존성, 상태 유지 |
| Spy | 부분 대체 | 일부만 변경 필요 |
선택 가이드
- Mock 3개 이상 필요 → Fake 또는 설계 재검토
- 상태 유지 필요 → Fake
- 호출 검증만 필요 → Mock
- 값만 필요 → Stub
프레임워크 제공
FakeRepository<T, ID>기반 클래스StubHelper,MockVerifyHelper유틸리티@TestDouble문서화 어노테이션
안티패턴
- 과도한 Mock (5개 이상)
- 구현 세부사항 검증
- Mock으로 복잡한 로직 시뮬레이션
댓글