용어 정리: Mock / Stub / Spy는 무엇이 다른가?

SUT / CUT

  • SUT (System Under Test): 테스트에서 실제 동작을 검증하려는 대상 (예: OrderService, PlaybackService 같은 실제 구현체)
  • CUT (Class Under Test): SUT를 “클래스” 관점으로 부르는 표현(실무에서 혼용됨)

Mock(목)과 Stub(스텁)의 차이 (핵심만)

둘 다 “테스트 더블(test double)”이지만, 테스트에서의 관심사가 다르다.

  • Mock(목): “의존성이 어떻게 호출되었는지”를 검증하기 위한 것

    • 관심사: 호출 여부 / 횟수 / 순서 / 인자
    • 대표 행위: verify(...)
  • Stub(스텁): “의존성이 호출되면 어떤 값을 반환(또는 예외)할지”를 미리 정해 SUT가 특정 경로로 진행하게 만드는 것

    • 관심사: 반환값 / 예외 / 연속 호출에 대한 응답
    • 대표 행위: whenever(...).thenReturn(...), doReturn(...).whenever(...), stub { ... }

실무에서는 같은 mock 객체에 대해 stubbing도 하고 verify도 하므로 “mock을 만든다/스텁한다”가 섞여 들릴 수 있다.
하지만 개념적으로는 stubbing(행동 세팅)verification(호출 검증) 을 분리해서 생각하면 테스트가 훨씬 읽기 쉬워진다.

Spy(스파이)는 Mock과 무엇이 다른가?

Spy는 ‘진짜 객체(real object)’를 감싸서(partial mock), 스텁하지 않은 호출은 실제 메서드를 실행할 수 있는 테스트 더블이다.

  • Mock: 기본적으로 실제 로직을 실행하지 않는 “가짜 객체”
  • Spy: “부분 mock”
    • stub 하지 않은 메서드는 실제 구현이 실행될 수 있음
    • 그래서 spy는 부작용/예외가 테스트에 그대로 나타날 수 있음

“mock을 만들고 whenever로 값을 반환하면 그게 stub가 되는가?” (관계 정리)

이 질문을 정확히 정리하려면 “객체”와 “행동”을 분리해서 보면 된다.

객체 관점

val repo = mock<UserRepository>()
  • repomock 객체다. (테스트 더블 인스턴스)

행동(설정) 관점

whenever(repo.findName("u-1")).thenReturn("Heeju")
  • 이 코드는 repo를 “stub로 바꾼다”기보다는,
  • repo(mock)에 stubbing(스텁 설정)을 추가한 것이다.

따라서 더 정확한 표현은:

  • ❌ “repo가 stub가 됐다”
  • ✅ “repo(mock)에 stubbing을 걸었다(스텁을 했다)”

결론(짧은 문장)

  • mock은 대상(객체) 이고
  • stub은 그 대상에 붙이는 ‘행동 세팅(stubbing)’ 이다.

즉, 하나의 mock을 stub처럼(반환값 세팅) 쓰기도 하고, mock처럼(verify) 쓰기도 한다.
Mockito 스타일에서 이 두 역할은 배타적이지 않다.


mockito-kotlin에서 사용법 차이: mock 생성 / stubbing / verify

mockito-kotlin은 Kotlin에서 아래 두 스타일을 많이 쓴다.

  • (A) mock 생성 시점에 stubbing을 같이 적는 방식
  • (B) 이미 만든 mock에 stub { ... } 블록으로 stubbing을 묶어 추가하는 방식

Mock 생성 + Stubbing을 한 번에: mock<T> { on { ... } doReturn ... }

interface ProfileRepository {
    fun nickname(userId: String): String
}
 
val repo = mock<ProfileRepository> {
    on { nickname("u-1") } doReturn "Heeju"
}
  • repo는 mock 객체
  • 블록 안의 on { ... } doReturn ...는 stubbing(스텁 설정)

whenever(...).thenReturn(...): 가장 직관적인 stubbing

val repo = mock<ProfileRepository>()
 
whenever(repo.nickname("u-1")).thenReturn("Heeju")

이미 만든 mock에 Stubbing을 ‘묶어서’ 추가: mock.stub { ... }

val repo = mock<ProfileRepository>()
 
repo.stub {
    on { nickname("u-1") } doReturn "Heeju"
}

이 형태가 특히 유용한 경우:

  • 공용 mock() 생성은 @Before에서 하고, 테스트별로 stub만 다르게 주고 싶을 때
  • 한 mock에 대해 여러 테스트가 다른 stubbing을 필요로 할 때
  • mock 생성 시점에 stubbing이 너무 많아져서 가독성이 떨어질 때 (행동 세팅을 분리)

stub { ... } 확장 함수는 무엇을 하는가?

inline fun <T : Any> T.stub(stubbing: KStubbing<T>.(T) -> Unit): T {
    return apply { KStubbing(this).stubbing(this) }
}

한 줄 요약

stub { ... }

  1. “이 객체(this)를 대상으로 KStubbing(this)를 만들고”
  2. “그 컨텍스트에서 stubbing 블록을 실행한 다음”
  3. “원래 객체를 그대로 반환”한다 (apply)

즉, ‘스텁을 쉽게 붙이는 유틸’ 이고, 스텁을 걸어도 “원래 mock을 계속 체이닝/주입”할 수 있게 해준다.

(KStubbing<T>.(T) -> Unit) 시그니처가 주는 효과

  • 리시버가 KStubbing<T>라서 블록 내부에서 on { ... } doReturn ... DSL을 자연스럽게 쓸 수 있다.
  • 파라미터로도 (T)를 받기 때문에, 필요하면 대상 mock을 인자로 직접 참조하는 형태로도 확장할 수 있다.

Spy(스파이) 실제로 이해하기: 언제 쓰고, 왜 doReturn이 자주 나오나?

Spy 기본 예시: “stub 안 한 건 실제로 실행될 수 있다”

class Counter {
    private var value = 0
    fun inc(): Int { value += 1; return value }
    fun get(): Int = value
}
 
@Test
fun `spy runs real method by default`() {
    val counterSpy = spy(Counter())
 
    // stub이 없으면 실제 메서드가 실행될 수 있음
    val v1 = counterSpy.inc()
    val v2 = counterSpy.inc()
 
    assertEquals(2, v2)
}

Spy에서 whenever(spy.method())가 위험한 이유

spy는 stub 하지 않은 호출이 실제로 실행될 수 있다.
그래서 아래처럼 stubbing을 걸려고 해도, spy.method()가 먼저 호출되면서 부작용/예외가 발생할 수 있다.

val listSpy = spy(mutableListOf<String>())
 
// ❌ 위험: stubbing을 거는 순간 get(0)이 실제로 실행되면서 IndexOutOfBoundsException이 날 수 있음
// whenever(listSpy[0]).thenReturn("first")

Spy에서는 doReturn(...).whenever(spy)...가 안전한 경우가 많다

doReturn 스타일은 “실제 호출을 유발하지 않고” stubbing을 걸 수 있어서 spy에 자주 사용된다.

val listSpy = spy(mutableListOf<String>())
 
// ✅ 안전: 실제 get(0) 호출을 하지 않고 stubbing을 건다
doReturn("first").whenever(listSpy)[0]

정리하면:

  • mock: whenever(...).thenReturn(...)가 보통 안전/직관적
  • spy: doReturn(...).whenever(...)가 더 안전한 상황이 많음 (실제 메서드 실행 회피)

“spy가 더 좋은 케이스”를 실제로 느껴보기 (복잡한 로직 + 많은 의존성)

“spy는 mock으로 다 처리할 수 있는데 더 쉽게 사용하기 위해 쓰는 건가?”라는 질문이 나올 때, 아래 유형이 가장 도움이 된다.

  • SUT는 비즈니스 계산/분기 로직이 복잡해서 진짜로 실행되어야 의미가 있음
  • 그런데 SUT 안에 “테스트에서 돌리기 싫은 부분(느림/환경의존/외부리소스/레거시)”이 몇 개의 내부 메서드로 섞여 있음
  • DI로 깔끔히 분리하면 제일 좋지만, 당장 리팩토링이 어렵다면 spy로 “딱 그 부분만” 바꿔치기할 수 있음

예시 코드: CheckoutService (복잡한 계산 로직 + 내부 heavy 메서드)

data class CartItem(val sku: String, val price: Long, val qty: Int)
data class CheckoutResult(val total: Long, val paymentId: String)
 
interface DiscountRepository {
    fun discountRateFor(userId: String, sku: String): Double
}
interface TaxClient {
    fun taxRate(country: String): Double
}
interface PaymentGateway {
    fun pay(amount: Long, token: String): String
}
 
open class CheckoutService(
    private val discountRepo: DiscountRepository,
    private val taxClient: TaxClient,
    private val paymentGateway: PaymentGateway,
) {
    fun checkout(userId: String, country: String, items: List<CartItem>, cardNumber: String): CheckoutResult {
        val subTotal = items.sumOf { it.price * it.qty }
 
        val discount = items.sumOf { item ->
            val rate = discountRepo.discountRateFor(userId, item.sku)
            (item.price * item.qty * rate).toLong()
        }
 
        val taxable = (subTotal - discount).coerceAtLeast(0)
        val taxRate = taxClient.taxRate(country)
        val total = taxable + (taxable * taxRate).toLong()
 
        // 테스트에서 실행되면 느리거나/환경 의존이라고 가정
        val token = encryptCard(cardNumber)
        val paymentId = paymentGateway.pay(total, token)
 
        return CheckoutResult(total = total, paymentId = paymentId)
    }
 
    // 현실에서는 private일 때가 많다. 여기서는 spy 예시를 위해 open으로 둠.
    open fun encryptCard(cardNumber: String): String {
        Thread.sleep(200)
        return "real-token-for-$cardNumber"
    }
}

spy를 사용한 테스트: “핵심 로직은 실제로 돌리고, heavy 부분만 막기”

@Test
fun `checkout calculates total and pays once - spy only the heavy part`() {
    val discountRepo = mock<DiscountRepository>()
    val taxClient = mock<TaxClient>()
    val paymentGateway = mock<PaymentGateway>()
 
    whenever(discountRepo.discountRateFor(eq("u-1"), any())).thenReturn(0.10) // 10% 할인
    whenever(taxClient.taxRate(eq("KR"))).thenReturn(0.10)                    // 10% 세금
    whenever(paymentGateway.pay(any(), any())).thenReturn("pay-999")
 
    val sut = spy(CheckoutService(discountRepo, taxClient, paymentGateway))
 
    // spy이므로 encryptCard가 실제로 실행될 수 있음 → doReturn으로 차단
    doReturn("token-123").whenever(sut).encryptCard(any())
 
    val items = listOf(
        CartItem("A", price = 1000, qty = 1),
        CartItem("B", price = 2000, qty = 1)
    )
 
    val result = sut.checkout("u-1", "KR", items, cardNumber = "4111-1111-1111-1111")
 
    // subtotal=3000, discount=10% => 300, taxable=2700, tax=10% => 270, total=2970
    assertEquals(2970L, result.total)
    assertEquals("pay-999", result.paymentId)
 
    verify(paymentGateway, times(1)).pay(eq(2970L), eq("token-123"))
}

“이 테스트에서 실제 로직이 돌아가는 게 도움이 되나?” 감 잡는 체크리스트

아래 질문에 “예”가 많으면, mock-only 테스트가 아니라 실제 로직 실행(SUT는 real object)이 의미가 커진다.

  • 테스트의 핵심이 계산/분기/상태변화의 정합성인가?
  • SUT를 mock으로 만들면, 테스트가 thenReturn(...) 세팅으로 도배되면서
    “내가 쓴 스텁이 맞는지”만 확인하는 형태가 되지 않는가?
  • 실패했을 때 “로직이 틀렸다”보다 “스텁을 잘못 달았다”가 먼저 의심되는가?
  • SUT 내부에서 “테스트를 방해하는 부분(느림/환경의존)”이 일부 메서드로 국한되어 있는가?

반대로 아래가 “예”라면 spy는 피하는 편이 안전하다.

  • spy 때문에 실제 메서드가 실행되면 네트워크/파일/시간 의존 등 부작용이 터질 가능성이 큰가?
  • 테스트가 내부 구현 호출 흐름에 과하게 결합되는가?

현실적인 문제: “바꿔치기하고 싶은 메서드가 private면 어떡하지?”

앞의 spy 예시는 이해를 위해 encryptCard()open으로 두었지만, 실제 코드에서는 private인 경우가 흔하다.

Mockito(mockito-kotlin 포함) 기준으로는 private 메서드를 직접 stubbing/verify 하는 방식은 정석이 아니다.
그래서 해결책은 “private를 억지로 mock”이 아니라 테스트 seam(틈)을 설계로 만드는 것이 된다.

private 로직을 협력 객체로 추출해서 DI 한다

이 방식이 가장 안정적이고 유지보수에 강하다.

프로덕션 코드 예시

interface CardEncryptor {
    fun encrypt(cardNumber: String): String
}
 
class CheckoutService2(
    private val encryptor: CardEncryptor,
    private val discountRepo: DiscountRepository,
    private val taxClient: TaxClient,
    private val paymentGateway: PaymentGateway,
) {
    fun checkout(userId: String, country: String, items: List<CartItem>, cardNumber: String): CheckoutResult {
        val subTotal = items.sumOf { it.price * it.qty }
        val discount = items.sumOf { item ->
            val rate = discountRepo.discountRateFor(userId, item.sku)
            (item.price * item.qty * rate).toLong()
        }
        val taxable = (subTotal - discount).coerceAtLeast(0)
        val taxRate = taxClient.taxRate(country)
        val total = taxable + (taxable * taxRate).toLong()
 
        val token = encryptor.encrypt(cardNumber)
        val paymentId = paymentGateway.pay(total, token)
 
        return CheckoutResult(total = total, paymentId = paymentId)
    }
}

테스트 코드 예시

@Test
fun `checkout with DI seam - no spy needed`() {
    val encryptor = mock<CardEncryptor>()
    val discountRepo = mock<DiscountRepository>()
    val taxClient = mock<TaxClient>()
    val paymentGateway = mock<PaymentGateway>()
 
    whenever(encryptor.encrypt(any())).thenReturn("token-123")
    whenever(discountRepo.discountRateFor(eq("u-1"), any())).thenReturn(0.10)
    whenever(taxClient.taxRate(eq("KR"))).thenReturn(0.10)
    whenever(paymentGateway.pay(any(), any())).thenReturn("pay-999")
 
    val sut = CheckoutService2(encryptor, discountRepo, taxClient, paymentGateway)
 
    val items = listOf(CartItem("A", 1000, 1), CartItem("B", 2000, 1))
    val result = sut.checkout("u-1", "KR", items, "4111-1111-1111-1111")
 
    assertEquals(2970L, result.total)
    verify(paymentGateway).pay(eq(2970L), eq("token-123"))
}

이렇게 바꾸면:

  • private 메서드를 건드릴 필요가 없어지고
  • 테스트가 내부 구현이 아니라 “협력 계약(interface)”에 붙어서 안정적이다

Verify(검증) 쪽 사용법: “Mock”을 Mock답게 쓰는 부분

interface Notifier {
    fun sendWelcome(email: String)
}
 
class SignupService(private val notifier: Notifier) {
    fun signup(email: String) {
        notifier.sendWelcome(email)
    }
}
 
@Test
fun `verify example`() {
    val notifier = mock<Notifier>()
    val sut = SignupService(notifier)
 
    sut.signup("a@company.com")
 
    verify(notifier).sendWelcome("a@company.com")
}

여기서 중요한 구분:

  • stubbing이 없어도 verify는 가능하다 (반환값 세팅이 필요 없으면 스텁을 안 해도 됨)
  • verify는 “호출을 검증”하는 행위이므로, mock을 쓰는 목적(상호작용 확인)이 드러난다

다음 글(매처)로 이어지는 연결고리

stub { ... }whenever(...)doReturn(...)이든 결국 “메서드 호출을 조건으로 행동을 세팅”한다는 점에서 matcher 규칙이 동일하게 적용된다.

  • matcher를 한 인자라도 쓰면 → 그 호출의 모든 인자를 matcher로 통일
  • matcher는 verify/stubbing에서만 사용 (SUT 호출 인자에는 실제 값)

이 내용은 별도 문서 **“Mockito Matcher 실전 가이드”**에서 이어서 다룬다.


참고 문서