용어 정리: 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>()repo는 mock 객체다. (테스트 더블 인스턴스)
행동(설정) 관점
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 { ... }는
- “이 객체(
this)를 대상으로KStubbing(this)를 만들고” - “그 컨텍스트에서 stubbing 블록을 실행한 다음”
- “원래 객체를 그대로 반환”한다 (
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 실전 가이드”**에서 이어서 다룬다.
참고 문서
- Mockito Javadoc (spying, doReturn 패밀리, partial mocks 관련 섹션): https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
- Mockito Javadoc (@Spy 문서 포함): https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Spy.html
- mockito-kotlin Wiki (Mocking and verifying): https://github-wiki-see.page/m/mockito/mockito-kotlin/wiki/Mocking-and-verifying
- mockito-kotlin Wiki (Stubbing / KStubbing DSL): https://github-wiki-see.page/m/mockito/mockito-kotlin/wiki/Stubbing