Mock, Stub, Fake는 어떻게 다를까

1. 개요

단위 테스트를 작성할 때, 보통 데이터베이스, 결제 게이트웨이, 이메일 서비스 같은 실제 의존성을 “대역” 객체로 바꿉니다. 이러한 객체들을 Test Double이라고 부릅니다. 많은 사람들이 모두를 “mock”이라고 부르지만, mock, stub, fake는 세 가지 다른 목적을 제공합니다.

이 글에서는 같은 예제를 통해 세 가지 유형을 구분하고, 각각을 언제 사용할지, 그리고 테스트를 깨지기 쉽게 만드는 실수를 피하는 방법을 배웁니다.

2. 전체에서 사용할 예제

2.1. 의존성

이 글에서는 JUnit 5, AssertJ, Mockito를 사용합니다. Spring Boot 3.x를 사용 중이라면 spring-boot-starter-test에 세 가지 모두 포함되어 있습니다. 그렇지 않다면 다음 의존성을 추가하세요:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.14.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

2.2. 테스트할 코드

간단한 OrderService가 있습니다. 이 서비스는 결제를 처리하고 주문을 저장한 후 확인 이메일을 보냅니다:

public record Order(String id, String customerEmail, BigDecimal amount) {}

public interface PaymentGateway {
    boolean charge(String customerEmail, BigDecimal amount);
}

public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
}

public interface EmailSender {
    void send(String to, String subject);
}

public class OrderService {

    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;
    private final EmailSender emailSender;

    public OrderService(PaymentGateway paymentGateway,
                        OrderRepository orderRepository,
                        EmailSender emailSender) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
        this.emailSender = emailSender;
    }

    public boolean placeOrder(Order order) {
        if (!paymentGateway.charge(order.customerEmail(), order.amount())) {
            return false;
        }
        orderRepository.save(order);
        emailSender.send(order.customerEmail(), "주문 " + order.id() + " 이(가) 확인되었습니다");
        return true;
    }
}

이 세 의존성은 서로 다른 유형의 test double에 적합합니다:

  • PaymentGateway는 입력 데이터를 제공합니다(결제 결과). stub에 적합합니다.
  • EmailSender는 검증해야 할 부수 효과입니다(이메일이 전송되었는지). mock에 적합합니다.
  • OrderRepository는 실제 저장소 동작을 합니다. fake에 적합합니다.

3. Stub: 미리 정해진 답변 반환하기

Stub은 호출에 대해 고정된 값을 반환하는 객체입니다. Stub은 자신이 몇 번 호출되는지 상관하지 않습니다. Stub에 대해 assert를 하지도 않습니다. Stub의 유일한 목적은 테스트 중인 코드를 검증하고 싶은 특정 로직 경로로 이동시키는 것입니다.

3.1. 직접 작성한 Stub

가장 간단한 방법은 interface를 직접 구현하는 것입니다:

class DecliningPaymentGateway implements PaymentGateway {
    @Override
    public boolean charge(String customerEmail, BigDecimal amount) {
        return false;
    }
}

이 클래스는 항상 결제를 거부합니다. 이를 사용하여 ‘결제 실패’ 경로를 테스트합니다:

class OrderServiceStubTest {

    @Test
    void givenDeclinedPayment_whenPlaceOrder_thenReturnFalse() {
        OrderService service = new OrderService(
                new DecliningPaymentGateway(),
                mock(OrderRepository.class),
                mock(EmailSender.class));

        boolean result = service.placeOrder(
                new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        assertThat(result).isFalse();
    }
}

이 테스트에서:

  • DecliningPaymentGateway는 stub입니다. 이는 placeOrder()를 return false 경로로 강제합니다.
  • Assert는 서비스의 반환 결과에 있으며, stub에는 없습니다.
  • 여기서 mock(OrderRepository.class)와 mock(EmailSender.class)는 dummy 역할만 합니다. 이들은 constructor 매개변수를 채우기 위해 전달되지만 사용되지는 않습니다.

3.2. Mockito로 Stub 만들기

각 시나리오마다 클래스를 직접 작성하면 빠르게 번거로워집니다. Mockito를 사용하면 테스트 내에서 바로 stub을 만들 수 있습니다:

@Test
void givenApprovedPayment_whenPlaceOrder_thenReturnTrue() {
    PaymentGateway paymentGateway = mock(PaymentGateway.class);
    when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);

    OrderService service = new OrderService(
            paymentGateway, mock(OrderRepository.class), mock(EmailSender.class));

    assertThat(service.placeOrder(
            new Order("ORD-2", "binh@example.com", new BigDecimal("99000"))))
            .isTrue();
}

mock() 함수로 생성되었지만, 여기서 paymentGateway는 역할 측면에서는 여전히 stub입니다: when(...).thenReturn(...)으로만 반환값을 구성하고 절대 verify하지 않습니다. 라이브러리 함수의 이름이 test double의 유형을 결정하지는 않습니다. 객체를 어떻게 사용하는지가 결정 요소입니다.

4. Mock: 상호작용 검증

Mock은 자신으로 가는 호출을 기록하는 객체로, 나중에 코드가 올바른 메서드를 올바른 매개변수로 올바른 횟수만큼 호출했는지 확인할 수 있습니다. Stub과 달리 assert는 mock 자체에 있습니다.

Mock은 이메일 전송, 이벤트 발행, 감사 로그 작성과 같이 아무것도 반환하지 않는 부수 효과를 검증해야 할 때 유용합니다.

@ExtendWith(MockitoExtension.class)
class OrderServiceMockTest {

    @Mock
    PaymentGateway paymentGateway;

    @Mock
    OrderRepository orderRepository;

    @Mock
    EmailSender emailSender;

    @InjectMocks
    OrderService orderService;

    @Test
    void givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail() {
        when(paymentGateway.charge("an@example.com", new BigDecimal("150000")))
                .thenReturn(true);

        orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        verify(emailSender).send("an@example.com", "주문 ORD-1 이(가) 확인되었습니다");
    }

    @Test
    void givenDeclinedPayment_whenPlaceOrder_thenNoEmailSent() {
        when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);

        orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        verifyNoInteractions(emailSender, orderRepository);
    }
}

각 부분 분석:

  • @ExtendWith(MockitoExtension.class)는 JUnit 5에서 Mockito를 활성화합니다. @Mock 필드는 각 테스트 전에 초기화됩니다.
  • @InjectMocks는 OrderService를 생성하고 constructor를 통해 세 개의 @Mock 객체를 주입합니다.
  • paymentGateway는 여전히 stub이지만, emailSender는 우리가 verify하기 때문에 진정한 mock입니다.
  • verify(emailSender).send(...)은 send()가 정확히 한 번 호출되고 정확히 두 개의 매개변수로 호출되었는지 확인합니다.
  • verifyNoInteractions(...)은 결제 실패 시 시스템이 주문을 저장하지도 이메일을 보내지도 않음을 단정합니다.

실수로 OrderService에서 emailSender.send(...) 라인을 삭제하면 첫 번째 테스트가 다음과 같은 메시지로 실패합니다:

Wanted but not invoked:
emailSender.send(
    "an@example.com",
    "주문 ORD-1 이(가) 확인되었습니다"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail

Actually, there were zero interactions with this mock.

주의: 같은 호출을 동시에 stub하고 verify하면 안 됩니다. 예를 들어 when(paymentGateway.charge(...))한 후 verify(paymentGateway).charge(...)를 하는 것입니다. Stub이 호출되지 않으면 서비스가 true를 반환하지 못했고 테스트는 다른 곳에서 실패합니다. 이 경우의 verify 명령은 불필요하며 테스트를 구현에 더 밀접하게 결합할 뿐입니다.

5. Fake: 실제 구현이지만 단순화된

Fake는 실제로 작동하는 구현이지만 프로덕션에 부적합한 단축을 사용합니다. 전형적인 예는 데이터베이스 대신 HashMap에 데이터를 저장하는 repository입니다:

public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> storage = new HashMap<>();

    @Override
    public void save(Order order) {
        storage.put(order.id(), order);
    }

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(storage.get(id));
    }
}

Stub과 달리 fake는 로직과 상태를 가집니다. save()를 호출한 후 findById()를 호출하면 방금 저장한 정확한 주문을 반환합니다. 이덕분에 호출을 확인하는 대신 최종 상태를 확인합니다:

class OrderServiceFakeTest {

    private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();

    private final OrderService orderService = new OrderService(
            (email, amount) -> true,      // stub 형태의 lambda
            orderRepository,               // fake
            (to, subject) -> { });         // lambda 형태의 dummy

    @Test
    void givenApprovedPayment_whenPlaceOrder_thenOrderIsPersisted() {
        Order order = new Order("ORD-3", "chi@example.com", new BigDecimal("250000"));

        orderService.placeOrder(order);

        assertThat(orderRepository.findById("ORD-3")).contains(order);
    }
}

한 가지 주목할 점:

  • PaymentGateway와 EmailSender는 단일 추상 메서드만 있으므로 Mockito 없이 lambda로 stub과 dummy를 만듭니다.
  • 테스트는 OrderService가 save()를 몇 번 호출하거나 어떤 순서로 호출하는지 알 필요가 없습니다. 테스트는 단지 주문을 한 후 그 주문이 저장소에 있는지 확인합니다.
  • 나중에 OrderService를 리팩터링하여 다른 메서드를 사용하도록 변경하면(예: saveAll()), fake를 사용하는 테스트는 최종 결과가 올바르면 계속 작동합니다. verify(orderRepository).save(order)를 사용하는 테스트는 깨질 것입니다.

주의: Fake도 코드이므로 bug가 있을 수 있습니다. Fake가 실제 구현과 다르게 동작하면(예: 중복 키에서 에러를 던지지 않으면) 테스트는 통과하지만 프로덕션에서는 실패할 수 있습니다. 중요한 fake의 경우 계약 테스트 스위트를 작성하여 fake와 실제 구현 모두에서 실행하세요(예: Testcontainers를 사용하는 JPA 버전).

6. 비교 및 선택 방법

아래 표는 차이점을 요약합니다:

기준 Stub Mock Fake
목적 입력 데이터 제공 상호작용 검증 실제 동작을 더 가볍게 대체
로직이 있나 없음, 고정된 값 반환 없음, 호출만 기록 있음, 실제처럼 작동
Assert 위치 테스트 중인 코드의 결과 Mock 자체에 (verify) Fake의 상태에서
테스트 유형 상태 기반 상호작용 기반 상태 기반
리팩터링 시 견고성 높음 낮음 높음

위의 세 가지 외에도 두 가지 유형을 더 만날 수 있습니다:

  • Dummy: 매개변수를 채우기 위해 전달되지만 절대 사용되지 않는 객체입니다.
  • Spy: 실제 객체를 감싸서 실제 로직은 여전히 실행하지만 호출을 기록하여 verify할 수 있습니다. Mockito에서는 spy(...)로 spy를 만듭니다.

간단한 선택 규칙입니다:

  1. 의존성이 코드에 데이터를 반환하면 stub을 사용합니다.
  2. 의존성이 외부 부수 효과(이메일, 메시지 큐, 제3자 API)이고 상태를 관찰할 방법이 없으면 mock을 사용합니다.
  3. 의존성이 상태를 가지고 여러 테스트에서 사용되면(repository, cache, clock) fake를 한 번 작성하고 많은 곳에서 재사용하는 것을 고려하세요.

일반적으로 상호작용 검증(mock)보다 상태 검증(stub, fake)을 우선하세요. 상태 기반 테스트는 결과를 설명하지만, 상호작용 기반 테스트는 방법을 설명하고, 방법은 더 자주 변경됩니다.

7. 흔한 실수들

모든 것을 Mock으로 만들기. 테스트에 5개의 when 명령과 4개의 verify 명령이 있다면, 그 테스트는 동작을 검증하는 것이 아니라 구현을 복사하고 있습니다. 단 두 줄의 코드 순서만 바꿔도 결과가 올바르더라도 테스트는 빨간색이 됩니다.

Value Object Mock하기. Order, BigDecimal, LocalDate를 mock하지 마세요. 실제 인스턴스를 만드세요. Mockito 5는 기본적으로 final 클래스와 record까지 mock할 수 있지만, 할 수 있다고 해서 해야 하는 것은 아닙니다.

소유하지 않은 타입 Mock하기. RestTemplate이나 공급업체의 SDK를 직접 mock하면 라이브러리의 동작에 대해 테스트가 잘못된 가정을 하게 됩니다. 대신 자신의 interface(예: PaymentGateway)로 라이브러리를 래핑한 후 그 interface를 mock하세요. 실제 구현은 integration 테스트로 확인하세요.

불필요한 Stub. MockitoExtension을 사용하면 Mockito는 기본적으로 strict stubs을 사용합니다. 코드가 실행하지 않는 호출을 stub하면 테스트가 실패합니다:

org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.

이 경고를 lenient()로 서둘러 끄지 마세요. 이 메시지는 보통 테스트가 생각한 경로로 가지 않거나 설정이 구식되었음을 나타냅니다.

Integration Test 대신 Fake 사용하기. InMemoryOrderRepository는 SQL, 제약 조건 또는 트랜잭션을 확인할 수 없습니다. Fake는 단위 테스트를 빠르게 해주지만, 실제 데이터베이스를 사용하는 integration 테스트가 여전히 필요합니다.

8. 결론

Stub, mock, fake는 모두 test double이지만 세 가지 다른 질문에 답합니다. Stub은 “의존성이 무엇을 반환하는가?“를 답합니다. Mock은 “코드가 의존성을 올바르게 호출하는가?“를 답합니다. Fake는 “실제이지만 가벼운 버전이 있다면 최종 결과는 무엇인가?“를 답합니다.

의존성의 역할에 따라 test double 유형을 선택하세요. 도구에 따라서가 아닙니다. mock()으로 만든 객체도 완전히 stub일 수 있습니다. 상태 검증을 우선하고, 정말 검증해야 하는 부수 효과에만 verify를 사용하세요. 테스트는 더 짧고, 읽기 쉽고, 리팩터링할 때 덜 깨질 것입니다.

testingmockitojunit5unit-testjava