광고
~ / posts / developer
💻 개발자

Mockito doReturn vs when 고르는 법, spy·매처·strict 스텁 점검표

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 고르는 판단표

void 메서드인지, spy인지, 예외 스텁을 덮어쓰는지, 인자 종류가 많은지를 차례로 물어 doThrow·doReturn·thenAnswer·when 중 하나를 고르는 순서도
위에서부터 질문하고 처음 '예'가 나오는 곳에서 멈춰요. 대부분은 맨 아래 when().thenReturn()에 도착해요
상황고를 것이유
반환값이 있는 일반 목 메서드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"oneany
1 → "one", 그다음 anyInt() → "any"anyany

두 번째 순서는 특정 값 스텁이 예외 없이 조용히 가려져요. @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() 구현부터 의심하는 게 순서예요.

엄격도를 풀 때는 범위를 가장 좁게 잡아요.

  1. 스텁 하나만: lenient().when(m.f(x)).thenReturn(v) — @BeforeEach 공통 스텁 중 일부 테스트만 안 쓰는 경우.
  2. 목 하나만: @Mock(strictness = Strictness.LENIENT) 또는 withSettings().strictness(...) — 공식 문서 52절, 4.6.0부터.
  3. 테스트 클래스 전체: @MockitoSettings(strictness = Strictness.LENIENT) — 이건 문제를 숨기는 경우가 많아 마이그레이션 기간에만 써요.

스텁 테스트가 깨질 때 확인 순서

InvalidUseOfMatchers, NullPointerException, PotentialStubbingProblem, UnnecessaryStubbing, WrongTypeOfReturnValue, UnfinishedStubbing 여섯 가지 예외별로 먼저 확인할 것과 고치는 법을 정리한 표
예외 이름으로 줄을 찾아요. 여섯 가지 모두 Mockito 5.23.0에서 직접 재현한 메시지예요

그림에 다 담지 못한 판단 포인트만 덧붙이면 이래요.

  1. InvalidUseOfMatchersException — 메시지의 "N matchers expected, M recorded"에서 M이 작으면 날값이 섞인 거고, M이 크면 앞 줄에서 괄호 밖에 쓴 매처가 남은 거예요.
  2. 스텁 줄의 NullPointerException — 스택이 ArgumentMatchers.any()가 아니라면 when(a.b().c())처럼 중간 목이 null을 돌려준 경우가 많아요.
  3. PotentialStubbingProblem — 메시지의 "this invocation"과 "stubbing(s) with different arguments" 두 줄의 인자를 비교하고, 값 객체면 equals() 구현을 보세요.
  4. UnnecessaryStubbingException — 공통 스텁을 쓰는 테스트로 옮기는 게 먼저고, lenient()는 그다음이에요.
  5. 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번 시간 (3회 중앙값)
스텁 1개스텁 1개 · 13µs13µs스텁 10개스텁 10개 · 16.5µs16.5µs스텁 100개스텁 100개 · 34µs34µs스텁 1000개스텁 1000개 · 230µs230µs

스텁을 나중 것부터 하나씩 맞춰 보기 때문에 스텁이 많을수록 호출이 길어져요.

단위: µs · 자료: Apple M1 · OpenJDK 24.0.2 · Mockito 5.23.0으로 직접 측정
표로 보기
구분호출 1번
스텁 1개13
스텁 10개16.5
스텁 100개34
스텁 1000개230

스텁 1,000개를 걸고 1,000개 인자를 한 번씩 부르는 테스트를 통째로 재면 차이가 더 분명해요. 1,000개 스텁은 거는 데만 약 183ms가 들었어요. 스텁을 하나 걸 때마다 when() 괄호 안의 호출이 이미 걸린 스텁을 훑기 때문이에요. 같은 결과를 when(r.find(anyInt())).thenAnswer(inv -> "v" + inv.getArgument(0)) 하나로 만들면 준비와 호출을 합쳐 약 29ms였어요.

인자 1000종류를 다루는 방법별 시간 (5회 중앙값)
개별 스텁 1000개 걸기개별 스텁 1000개 걸기 · 183ms183ms개별 스텁으로 1000번 호출개별 스텁으로 1000번 호출 · 156ms156msthenAnswer 1개로 준비+1000번 호출thenAnswer 1개로 준비+1000번 호출 · 29ms29ms

개별 스텁은 걸기와 호출을 합쳐 약 339ms예요. 규칙이 있는 값은 thenAnswer 하나가 10배 이상 빨라요.

단위: ms · 자료: Apple M1 · OpenJDK 24.0.2 · Mockito 5.23.0으로 직접 측정
표로 보기
구분걸린 시간
개별 스텁 1000개 걸기183
개별 스텁으로 1000번 호출156
thenAnswer 1개로 준비+1000번 호출29

이 숫자로 판단 기준을 계산해 보면 이래요.

도입 전에 따질 것 — 목 대신 진짜·가짜 객체가 나은 경우

스텁 문법을 고르기 전에 "이걸 목으로 만들어야 하나"를 먼저 따지면 위의 문제 상당수가 처음부터 생기지 않아요.

광고
광고

자주 묻는 질문

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로 바꾸면 돼요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 Mockito 목 객체 생성 원리 — 바이트코드 프록시와 인라인 MockMaker에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#Mockito doReturn#when thenReturn 차이#Mockito spy#UnnecessaryStubbingException#PotentialStubbingProblem#InvalidUseOfMatchersException#Mockito lenient#RETURNS_DEEP_STUBS
← 이전 글스프링 배치 청크 크기 정하는 법, 재시작·skip 설정 판단표와 장애 점검다음 글 →JPA 애그리거트 루트 버전 안 오를 때, 강제 증가 판단표와 비용
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →