when(spy.get(0))에서 갑자기 IndexOutOfBoundsException이 나거나, JUnit 5로 옮기자마자 멀쩡하던 테스트가 UnnecessaryStubbingException으로 무더기 실패한 적 있으시죠. 결론부터 말하면 스텁은 기본적으로 when(...).thenReturn(...)으로 쓰고, void 메서드·spy·예외를 건 스텁을 덮어쓸 때만 doReturn 계열로 바꾸면 대부분의 혼란이 정리돼요. strict 스텁 오류는 테스트를 느슨하게 풀 신호가 아니라, 스텁과 실제 호출이 어긋났다는 신호로 읽는 게 맞아요.
이 글은 스텁을 어떤 문법으로 걸지 정하는 판단표, 예외 이름으로 원인을 찾는 확인 순서, 그리고 스텁 개수와 호출 횟수가 테스트 시간에 얼마나 영향을 주는지 직접 잰 숫자를 정리했어요. 목 메이커 선택과 -javaagent 설정은 Mockito 목 메이커 선택표와 javaagent 경고 해결 순서에서 따로 다뤘어요.
Mockito 스텁 문법 네 가지, 무엇이 다른가요
Mockito에서 목의 반환값을 정하는 방법은 크게 네 가지예요. 공식 Mockito Javadoc 2·11·12·13절에 각각 나와 있어요. 차이는 "스텁을 거는 순간 대상 메서드가 실제로 불리느냐"와 "반환 타입을 컴파일 때 검사하느냐" 두 가지로 정리돼요.
| 문법 | 스텁 거는 순간 대상 메서드 호출 | 반환 타입 컴파일 검사 | void 메서드 | 주로 쓰는 곳 |
|---|---|---|---|---|
when(m.f(x)).thenReturn(v) | 불림 (목이면 기본값, spy면 진짜 코드) | 됨 | 불가 | 일반 목 대부분 |
doReturn(v).when(m).f(x) | 안 불림 | 안 됨 (Object로 받음) | 불가(doNothing 사용) | spy, 덮어쓰기 |
doThrow(e).when(m).f(x) | 안 불림 | — | 가능 | void 메서드 예외 |
when(...).thenAnswer(inv -> ...) | 불림 | 됨 | 불가(doAnswer 사용) | 인자에 따라 값 계산 |
when() 문법은 괄호 안의 m.f(x)가 먼저 실행된 뒤에 when()이 불려요. 목이면 기본값을 돌려줄 뿐이지만 spy라면 진짜 코드가 그대로 실행돼요. 반대로 doReturn은 값을 Object로 받아서 컴파일러가 타입을 확인해 주지 않아요. 문자열을 돌려줘야 할 자리에 숫자를 넣어도 컴파일은 통과하고 실행할 때 WrongTypeOfReturnValue로 실패해요.
그래서 실무에서는 타입 안전한 when을 기본으로, 호출이 미리 일어나면 곤란한 경우만 doReturn으로 나누는 게 가장 덜 헷갈려요.
상황별로 when·doReturn·spy 고르는 판단표
| 상황 | 고를 것 | 이유 |
|---|---|---|
| 반환값이 있는 일반 목 메서드 | when().thenReturn() | 타입 검사가 되고 읽기 쉬워요 |
| void 메서드가 예외를 던지게 | doThrow(e).when(m).save(x) | when() 괄호 안에 void 호출을 넣을 수 없어요 |
| void 메서드에 부수 효과(인자 캡처, 카운트) | doAnswer(inv -> ...).when(m).save(x) | 인자를 꺼내 쓸 수 있어요 |
| spy의 일부 메서드만 바꾸기 | doReturn(v).when(spy).f(x) | when(spy.f(x))는 진짜 f(x)를 먼저 실행해요 |
앞에서 thenThrow를 건 메서드를 다른 값으로 | doReturn(v).when(m).f(x) | when(m.f(x)) 재호출이 앞의 예외를 터뜨려요 |
| 연속 호출마다 다른 값 | thenReturn(a, b, c) | 마지막 값이 이후 호출에 계속 쓰여요 |
| 인자 값에 따라 결과가 규칙적으로 바뀜 | thenAnswer 1개 | 스텁을 인자마다 따로 거는 것보다 빠르고 짧아요 |
| 목 대신 진짜 동작 일부가 필요 | 작은 가짜 구현 클래스 | spy·부분 목은 공식 문서도 레거시 코드용으로 권해요 |
spy와 "예외 스텁 덮어쓰기" 두 줄은 Mockito 5.23.0에서 직접 재현해 확인했어요. 빈 ArrayList의 spy에 when(spy.get(0)).thenReturn("x")를 걸면 진짜 get(0)이 먼저 실행돼 IndexOutOfBoundsException: Index 0 out of bounds for length 0이 나요. 같은 스텁을 doReturn("x").when(spy).get(0)으로 바꾸면 예외 없이 "x"가 나오고 size()는 여전히 0이에요. 또 when(r.find(1)).thenThrow(...) 뒤에 when(r.find(1)).thenReturn("x")를 쓰면 두 번째 줄의 r.find(1)이 첫 번째 스텁을 실행해 예외가 그 자리에서 터져요. doReturn으로 덮어쓰면 문제없어요.
spy에 대해서는 하나 더 알아 둘 게 있어요. 공식 문서 13절대로 spy는 원래 객체를 감싸지 않고 복사본을 만들어요. 카운터 객체를 spy로 만든 뒤 spy에서 두 번, 원래 객체에서 한 번 증가시키면 원래 객체는 1, spy는 2로 따로 놀아요. 원래 객체를 다른 곳에 넘겨 놓고 spy만 검증하면 "분명히 불렀는데 verify가 실패한다"는 상황이 생겨요. 테스트 대상에 넘기는 객체가 spy 자신인지 꼭 확인하세요.
인자 매처 쓰는 규칙 — any·eq·anyInt 판단표
인자 매처(any(), eq(), anyInt() 등)는 ArgumentMatchers Javadoc에 목록이 있어요. 실무에서 걸리는 규칙은 세 가지예요.
1. 한 호출 안에서 매처를 하나라도 쓰면 전부 매처로 써요. when(r.find2(anyInt(), "kim"))은 실행할 때 InvalidUseOfMatchersException이 나고 메시지에 2 matchers expected, 1 recorded가 찍혀요. 날값 "kim"을 eq("kim")으로 감싸면 해결돼요. 공식 문서 3절대로 매처는 내부 스택에 기록만 하고 가짜 값을 돌려주므로, 괄호 밖에서 변수에 미리 담아 두는 것도 안 돼요.
2. 기본형 인자에는 기본형 전용 매처를 써요. int를 받는 메서드에 when(r.find(any()))를 쓰면 any()가 돌려준 null을 int로 풀다가 NullPointerException이 나요. 직접 돌려 본 메시지는 Cannot invoke "java.lang.Integer.intValue()" because the return value of "org.mockito.ArgumentMatchers.any()" is null이에요. anyInt()·anyLong()·anyBoolean()은 0이나 false를 돌려줘서 이 문제가 없어요.
3. 넓은 스텁을 먼저, 좁은 스텁을 나중에 걸어요. 같은 메서드에 스텁이 여럿이면 나중에 건 것부터 맞춰 봐요. 직접 확인한 결과는 다음과 같아요.
| 스텁 거는 순서 | find(1) 결과 | find(2) 결과 |
|---|---|---|
anyInt() → "any", 그다음 1 → "one" | one | any |
1 → "one", 그다음 anyInt() → "any" | any | any |
두 번째 순서는 특정 값 스텁이 예외 없이 조용히 가려져요. @BeforeEach에서 넓은 스텁을, 각 테스트에서 좁은 스텁을 거는 구조가 안전해요.
| 인자 상황 | 쓸 매처 | 피할 것 |
|---|---|---|
| 정확한 값, equals()가 잘 정의됨 | 날값 그대로 (매처 없이) | 불필요한 eq() 남발 |
| 값은 상관없고 타입만 | any(Foo.class), anyString() | 기본형 자리의 any() |
| 객체의 필드 하나만 확인 | argThat(u -> u.id() == 1) | 복잡한 람다 여러 단계 |
| 넘긴 객체를 꺼내 자세히 검사 | ArgumentCaptor (verify 쪽) | 스텁에 캡처 사용 |
| 여러 인자 중 하나만 고정 | 나머지도 any..() + 고정값은 eq() | 날값과 매처 섞기 |
strict 스텁 설정 고르기 — STRICT_STUBS·WARN·lenient
Mockito는 원래 느슨한(loose) 프레임워크예요. Mockito.mock()으로 그냥 만든 목은 쓰지 않은 스텁이 있어도, 다른 인자로 불려도 아무 말을 하지 않아요. 반면 JUnit 5의 MockitoExtension은 MockitoSettings Javadoc에 적힌 대로 기본 엄격도가 STRICT_STUBS예요. 같은 테스트 코드가 확장을 붙이는 순간 실패하기 시작하는 이유가 이거예요.
Strictness Javadoc에 정리된 세 단계를 실무 기준으로 옮기면 다음과 같아요.
| 단계 | 쓰지 않은 스텁 | 다른 인자로 호출 | 언제 고르나 |
|---|---|---|---|
STRICT_STUBS | 테스트 실패 (UnnecessaryStubbingException) | 즉시 실패 (PotentialStubbingProblem) | 새로 쓰는 테스트, 기본값으로 권장 |
WARN | 콘솔 경고만 | 콘솔 경고만 | 공식 문서도 "콘솔은 대개 안 본다"며 비권장 |
LENIENT | 아무 일 없음 | 기본값 반환 | 옮겨 오는 중인 오래된 테스트, 일부 목만 |
PotentialStubbingProblem에는 잘 알려지지 않은 조건이 하나 있어요. Mockito 소스의 DefaultStubbingLookupListener는 스텁을 건 위치와 호출 위치가 같은 소스 파일이면 인자 불일치로 보지 않아요. 테스트 코드가 직접 다른 인자로 부른 건 의도로 간주한다는 뜻이에요. 실제로 테스트 파일 안에서 find(1)을 스텁하고 같은 파일에서 find(2)를 부르면 null이 나오고 끝에 UnnecessaryStubbingException만 나요. 반면 다른 파일의 서비스 클래스가 find(2)를 부르면 그 자리에서 Strict stubbing argument mismatch 메시지와 함께 PotentialStubbingProblem이 나요. 즉 이 예외는 "운영 코드가 테스트가 예상한 인자와 다르게 부른다"는 뜻이라, 인자를 만드는 운영 코드나 equals() 구현부터 의심하는 게 순서예요.
엄격도를 풀 때는 범위를 가장 좁게 잡아요.
- 스텁 하나만:
lenient().when(m.f(x)).thenReturn(v)—@BeforeEach공통 스텁 중 일부 테스트만 안 쓰는 경우. - 목 하나만:
@Mock(strictness = Strictness.LENIENT)또는withSettings().strictness(...)— 공식 문서 52절, 4.6.0부터. - 테스트 클래스 전체:
@MockitoSettings(strictness = Strictness.LENIENT)— 이건 문제를 숨기는 경우가 많아 마이그레이션 기간에만 써요.
스텁 테스트가 깨질 때 확인 순서
그림에 다 담지 못한 판단 포인트만 덧붙이면 이래요.
InvalidUseOfMatchersException— 메시지의 "N matchers expected, M recorded"에서 M이 작으면 날값이 섞인 거고, M이 크면 앞 줄에서 괄호 밖에 쓴 매처가 남은 거예요.- 스텁 줄의
NullPointerException— 스택이ArgumentMatchers.any()가 아니라면when(a.b().c())처럼 중간 목이 null을 돌려준 경우가 많아요. PotentialStubbingProblem— 메시지의 "this invocation"과 "stubbing(s) with different arguments" 두 줄의 인자를 비교하고, 값 객체면equals()구현을 보세요.UnnecessaryStubbingException— 공통 스텁을 쓰는 테스트로 옮기는 게 먼저고,lenient()는 그다음이에요.UnfinishedStubbingException—thenReturn(helper())안에서 다른 목을 부르면 스텁이 중간에 끊겨요. 값을 변수로 먼저 만드세요.
예외는 나지 않는데 값이 이상하다면 순서가 달라져요. 넓은 스텁이 좁은 스텁을 가리지 않았는지(앞 절의 표), 테스트 대상에 넘긴 객체가 spy인지 원본인지, reset()을 테스트 중간에 부르지 않았는지 보세요. 공식 문서 17절은 테스트 중간의 reset()을 "너무 많이 테스트하고 있다는 신호"라고 적어요.
null 때문에 원인을 찾기 어렵다면 mock(Foo.class, RETURNS_SMART_NULLS)를 임시로 써 볼 만해요. 스텁하지 않은 메서드가 문자열이면 빈 문자열을, 객체면 "어디서 스텁 안 된 호출이 있었는지"를 알려 주는 대리 객체를 돌려줘요. 원인을 찾은 뒤에는 기본값으로 되돌리세요.
숫자로 보는 비용 — 스텁 개수와 목 호출 횟수
목은 편하지만 공짜가 아니에요. Apple M1, OpenJDK 24.0.2, Mockito 5.23.0, Byte Buddy 1.18.15에서 -javaagent를 넣고 직접 재 봤어요. 절대값은 기계마다 다르니 자릿수와 비율을 보세요.
먼저 호출 한 번의 비용이에요. 인자 하나를 받는 인터페이스를 람다로 만든 가짜 구현은 호출당 약 6ns였고, 같은 메서드에 스텁 하나를 건 목은 약 12.9µs였어요(20만 번 호출, 7회 중앙값). 대략 2,000배예요. 호출 기록을 끈 stubOnly() 목도 약 11.4µs로 거의 같았어요. 비용 대부분이 기록보다 호출을 가로채고 스텁을 찾는 과정에서 나온다는 뜻이에요.
다음은 같은 메서드에 스텁을 몇 개 걸었느냐에 따른 호출 비용이에요. 인자마다 when(r.find(i)).thenReturn(...)을 걸고, 가장 먼저 건 스텁(나중 것부터 훑으니 가장 늦게 찾아지는 스텁)을 반복 호출했어요.
스텁 1,000개를 걸고 1,000개 인자를 한 번씩 부르는 테스트를 통째로 재면 차이가 더 분명해요. 1,000개 스텁은 거는 데만 약 183ms가 들었어요. 스텁을 하나 걸 때마다 when() 괄호 안의 호출이 이미 걸린 스텁을 훑기 때문이에요. 같은 결과를 when(r.find(anyInt())).thenAnswer(inv -> "v" + inv.getArgument(0)) 하나로 만들면 준비와 호출을 합쳐 약 29ms였어요.
이 숫자로 판단 기준을 계산해 보면 이래요.
- 반복문 안의 목 호출: 배치 로직 테스트가 목 저장소를 10만 번 부르면 12.9µs × 100,000 = 약 1.3초예요. 같은 일을 가짜 구현으로 하면 1ms도 안 걸려요. 테스트 하나가 1초 넘게 걸리고 목 호출 횟수가 수만 번 이상이면 가짜 구현으로 바꿀 후보예요.
- 스텁이 많은 테스트: 테스트 하나에 스텁 10개 정도는 호출 비용이 13µs에서 16.5µs로 늘 뿐이라 신경 쓸 필요가 없어요. 같은 메서드에 스텁이 100개를 넘으면 그 메서드는 사실상 "규칙이 있는 함수"이니
thenAnswer나 가짜 구현으로 표현하는 게 읽기에도 나아요. - deep stub:
RETURNS_DEEP_STUBS로a.b().c()세 단계를 한 줄에 스텁하는 것과 목 세 개를 손으로 엮는 것은 각각 약 98µs로 비슷했어요. 속도로는 고를 이유가 없고, 공식 문서 32절이 "목이 목을 돌려주는 건 대부분 잘못"이라고 적은 설계 문제를 먼저 보세요.
도입 전에 따질 것 — 목 대신 진짜·가짜 객체가 나은 경우
스텁 문법을 고르기 전에 "이걸 목으로 만들어야 하나"를 먼저 따지면 위의 문제 상당수가 처음부터 생기지 않아요.
- 값 객체·컬렉션·record: 진짜 인스턴스를 쓰세요. 목으로 만들면
equals()가 기본값으로 바뀌어 매처 문제와PotentialStubbingProblem이 함께 따라와요. - 메서드가 적고 규칙이 단순한 저장소:
Map을 감싼 가짜 구현이 목보다 빠르고, 저장한 값을 다시 읽는 흐름까지 진짜처럼 확인돼요. - 외부 경계(HTTP 클라이언트, 메시지 발행, 시계): 목이 잘 맞는 자리예요. 호출 여부와 인자를
verify나ArgumentCaptor로 확인하는 게 테스트의 목적이기 때문이에요. - spy가 필요해 보이는 클래스: 한 클래스의 메서드 일부만 바꾸고 싶다면, 바꾸고 싶은 부분을 별도 협력 객체로 분리할 신호예요. 공식 문서 16절도 부분 목을 레거시 코드와 외부 인터페이스용으로 한정해요.
- DB 쿼리 자체가 검증 대상: 저장소를 목으로 만들면 쿼리는 한 번도 실행되지 않아요. 이 경우는 실제 DB를 띄우는 통합 테스트가 맞고, 수명 설정과 CI 비용은 Testcontainers 수명 고르는 법과 CI 시간 계산에 정리했어요.
자주 묻는 질문
doReturn을 항상 쓰면 더 안전하지 않나요?
공식 문서 12절도 "모든 스텁에 doReturn을 써도 된다"고 적어요. 다만 doReturn은 반환 타입을 컴파일 때 검사하지 않아서 타입 실수가 실행할 때 WrongTypeOfReturnValue로 드러나요. 일반 목은 when().thenReturn(), spy·void·덮어쓰기에만 doReturn 계열로 나누는 쪽이 실수가 적어요.
UnnecessaryStubbingException을 끄려면 lenient를 클래스 전체에 걸면 되나요?
끌 수는 있지만 권하지 않아요. 클래스 전체를 느슨하게 하면 잘못된 인자로 불리는 진짜 버그도 함께 숨어요. 공통 스텁 중 일부 테스트만 쓰는 스텁이 원인이면 그 스텁을 해당 테스트로 옮기고, 꼭 필요하면 lenient().when(...)으로 그 한 줄만 풀어요.
spy에 when을 썼더니 진짜 메서드가 실행돼요. 버그인가요?
버그가 아니라 자바 문법 순서 때문이에요. when(spy.get(0))은 괄호 안의 spy.get(0)을 먼저 실행하고, spy는 스텁 전이라 진짜 코드를 돌려요. doReturn(v).when(spy).get(0)으로 바꾸면 진짜 메서드에 닿지 않아요.
PotentialStubbingProblem은 왜 어떤 테스트에서는 안 나나요?
스텁을 건 곳과 호출한 곳이 같은 소스 파일이면 Mockito가 테스트의 의도로 보고 불일치로 처리하지 않아요. 운영 코드(다른 파일)가 다른 인자로 부를 때만 나요. 그래서 테스트 안에서 직접 목을 불러 확인할 때는 이 예외 대신 끝에 UnnecessaryStubbingException이 날 수 있어요.
목 호출이 느리다는데 테스트에서 신경 써야 하나요?
대부분은 아니에요. 측정한 호출 한 번이 약 13µs라 테스트 하나에서 수백 번 불러도 수 밀리초예요. 반복문으로 수만~수십만 번 부르는 배치·집계 로직 테스트, 같은 메서드에 스텁을 수백 개 거는 테스트만 가짜 구현이나 thenAnswer로 바꾸면 돼요.
정리
- 스텁은
when().thenReturn()을 기본으로 쓰고, void 메서드는doThrow·doNothing·doAnswer, spy와 예외 스텁 덮어쓰기는doReturn으로 바꿔요. - 매처는 전부 아니면 전무, 기본형에는
anyInt()같은 전용 매처, 같은 메서드의 스텁은 넓은 것을 먼저 걸어요. MockitoExtension의 기본값은STRICT_STUBS예요.PotentialStubbingProblem은 운영 코드가 다른 인자로 부른다는 신호이고, 엄격도는 스텁 한 줄 → 목 하나 → 클래스 순으로 좁게 풀어요.- 목 호출 한 번은 약 13µs로 가짜 구현보다 수천 배 느려요. 수만 번 넘게 부르거나 스텁이 수백 개면
thenAnswer나 가짜 구현이 빠르고 읽기도 쉬워요. - 목 메이커와
-javaagent설정은 Mockito 목 메이커 선택표, DB까지 확인해야 하는 테스트는 Testcontainers 수명 고르는 법을 이어서 보세요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 Mockito 목 객체 생성 원리 — 바이트코드 프록시와 인라인 MockMaker에서 도식과 코드로 단계별로 풀었어요.