테스트 더블
정의
테스트 더블(Test Double)은 테스트에서 실제 협력 객체를 대신하는 모든 대역을 가리키는 총칭이다. 영화의 스턴트 더블(stunt double)에서 온 이름으로, Gerard Meszaros가 xUnit Test Patterns에서 정리했다.
대역이 필요한 이유는 협력 객체가 다음 중 하나에 해당할 때다.
- 아직 구현되지 않았다
- 실행이 느리다 (DB, 네트워크)
- 결과가 매번 다르다 (현재 시각, 랜덤, 외부 시세)
- 원하는 상태를 만들기 어렵다 (특정 예외, 네트워크 타임아웃)
- 호출하면 부작용이 있다 (실제 결제, 메일 발송)
일상적으로는 이 모두를 “목(mock)“이라고 뭉뚱그려 부르지만, 다섯 가지는 목적이 서로 다르고 그중 하나만 검증에 관여한다.
다섯 가지 분류
| 종류 | 역할 | 반환값 | 검증에 사용 |
|---|---|---|---|
| Dummy | 자리만 채운다. 호출되지 않는다 | 없음 | 아니오 |
| Fake | 단순화된 실제 동작을 한다 | 실제로 계산된 값 | 아니오 (상태로 간접 확인) |
| Stub | 미리 정해둔 값을 돌려준다 | 고정된 값 | 아니오 |
| Spy | Stub이면서 호출 사실을 기록한다 | 고정된 값 | 예 (기록을 나중에 조회) |
| Mock | 기대한 호출을 미리 지정하고 검증한다 | 지정된 값 | 예 (검증이 본래 목적) |
핵심 구분선은 검증에 참여하는가다. Dummy·Fake·Stub은 테스트를 성립시키기 위한 배경이고, Spy·Mock은 테스트의 판정 근거 자체다. Fowler가 “Mocks Aren’t Stubs"에서 강조한 것도 이 경계다.
Dummy
파라미터를 채우기 위해서만 존재한다. 호출되지 않으므로 내용이 필요 없고, null이나 빈 구현이면 충분하다.
@Test
void 회원가입시_이름이_비어있으면_예외() {
// 검증이 이름 유효성에서 끝나므로 mailSender는 호출되지 않는다
MailSender dummy = null;
SignUpService service = new SignUpService(dummy);
assertThatThrownBy(() -> service.signUp("", "a@b.com"))
.isInstanceOf(IllegalArgumentException.class);
}
Fake
가볍지만 실제로 동작하는 구현이다. 인메모리 저장소가 대표적이며, 실 구현과 계약은 같지만 프로덕션에 쓰기에는 부족한 방식으로 만든다.
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> store = new HashMap<>();
private long sequence = 0L;
@Override
public User save(User user) {
User saved = user.withId(++sequence);
store.put(saved.id(), saved);
return saved;
}
@Override
public Optional<User> findByEmail(String email) {
return store.values().stream()
.filter(u -> u.email().equals(email))
.findFirst();
}
}
Fake는 상태를 실제로 보관하므로 “저장한 뒤 조회하면 나온다” 같은 시나리오를 그대로 테스트할 수 있다. Stub으로 같은 흐름을 만들려면 호출마다 응답을 일일이 지정해야 하고, 그 지정 자체가 구현 순서에 결합된다. 여러 테스트에서 반복 사용되는 저장소 계열 의존은 Fake가 유지보수 면에서 유리하다.
Stub
입력을 통제하기 위한 대역이다. 정해진 값을 돌려주는 것이 전부이고, Stub이 어떻게 호출됐는지는 검증하지 않는다.
@Test
void 환율이_1300이면_100달러는_13만원이다() {
ExchangeRateClient rateClient = mock(ExchangeRateClient.class);
given(rateClient.rateOf("USD")).willReturn(1_300); // Stub
PriceConverter converter = new PriceConverter(rateClient);
int won = converter.toWon(100, "USD");
assertThat(won).isEqualTo(130_000); // 검증 대상은 계산 결과
}
예외 상황을 만드는 데도 쓰인다. 네트워크 타임아웃을 실제로 재현하는 것보다 Stub이 예외를 던지게 하는 편이 확실하다.
given(rateClient.rateOf("USD")).willThrow(new TimeoutException());
Spy
Stub의 동작에 호출 기록이 더해진 것이다. 실행이 끝난 뒤 기록을 조회해 검증한다. 목과 달리 기대를 미리 선언하지 않으므로 Given-When-Then 흐름이 자연스럽게 유지된다.
수동으로 만들면 구조가 분명하게 드러난다.
public class SpyMailSender implements MailSender {
private final List<String> sentTo = new ArrayList<>();
@Override
public void send(String to, String body) {
sentTo.add(to);
}
public List<String> sentTo() {
return sentTo;
}
}
@Test
void 가입이_완료되면_환영메일을_보낸다() {
SpyMailSender mailSender = new SpyMailSender();
SignUpService service = new SignUpService(mailSender);
service.signUp("김철수", "chulsoo@example.com");
assertThat(mailSender.sentTo()).containsExactly("chulsoo@example.com");
}
Mock
기대하는 호출 자체가 명세인 대역이다. 어떤 메서드가 어떤 인자로 몇 번 호출되어야 하는지를 지정하고, 그 기대가 어긋나면 테스트가 실패한다.
@Test
void 결제가_승인되면_영수증_발행을_요청한다() {
ReceiptIssuer issuer = mock(ReceiptIssuer.class);
PaymentService service = new PaymentService(issuer);
service.approve(new Payment("ORD-1", 50_000));
verify(issuer, times(1)).issue("ORD-1", 50_000);
verifyNoMoreInteractions(issuer);
}
Mock이 정당한 경우는 반환값이 없어서 상태로 확인할 방법이 없을 때다. 메일 발송, 이벤트 발행, 외부 시스템 알림처럼 부작용이 곧 요구사항인 협력이 여기에 해당한다. 반환값으로 결과를 확인할 수 있는 상황에서 verify를 쓰는 것은 구현을 그대로 베껴 쓰는 것에 가깝다.
Mockito와의 매핑
Java에서는 다섯 종류를 대부분 Mockito 하나로 만든다. 같은 mock() 객체라도 어떻게 쓰느냐에 따라 역할이 달라진다는 점이 혼동의 원인이다.
| 개념 | Mockito 코드 | 판별 기준 |
|---|---|---|
| Dummy | mock(Foo.class) (설정도 검증도 없음) | 아무 설정 없이 넘기기만 함 |
| Stub | given(...).willReturn(...) | 반환값만 지정 |
| Spy | spy(realObject), ArgumentCaptor | 실제 동작 + 기록 |
| Mock | verify(...) | verify를 쓰는 순간 Mock |
Mockito의 spy()는 Meszaros의 Spy와 완전히 같지 않다. Mockito에서 spy()는 실제 객체를 감싸 일부 메서드만 덮어쓰는 부분 목(partial mock)이며, 호출 기록 목적으로는 mock() + ArgumentCaptor 조합이 더 흔하게 쓰인다.
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(mailSender).send(captor.capture(), any());
assertThat(captor.getValue()).isEqualTo("chulsoo@example.com");
무엇을 대체하고 무엇을 대체하지 않는가
대체 여부를 판단하는 기준은 “그 의존이 프로세스 밖으로 나가는가“와 “결과가 통제 불가능한가“다.
| 대상 | 처리 |
|---|---|
| 값 객체, 도메인 엔티티 | 대체하지 않는다. 실제 객체를 생성 |
| 순수 계산 로직 | 대체하지 않는다 |
| 같은 모듈 안의 협력 객체 | 가능하면 실제 객체 (Classicist 스타일) |
| Repository, DAO | Fake(인메모리) 또는 Stub |
| 외부 API 클라이언트 | Stub. 부작용이 요구사항이면 Mock |
| 현재 시각, 랜덤, UUID | 인터페이스로 감싸 Stub (Clock 주입 등) |
| 메일·SMS·이벤트 발행 | Mock 또는 Spy |
값 객체를 목으로 만드는 것은 대표적인 오용이다. mock(Money.class)처럼 만들면 Money의 실제 계산 규칙이 사라지므로, 테스트는 통과하지만 아무것도 검증하지 못한다.
과용이 만드는 문제
테스트가 구현을 그대로 베낀다
목 설정이 프로덕션 코드의 호출 순서를 한 줄씩 따라가는 상태가 되면, 그 테스트는 코드가 “무엇을 해야 하는가"가 아니라 “어떻게 쓰여 있는가"를 검증한다. 동작을 유지한 리팩터링에도 테스트가 깨지고, 반대로 진짜 버그는 잡지 못한다.
목이 실제와 다르게 동작한다
given(repo.findById(1L)).willReturn(Optional.of(user))로 세운 가정이 실제 구현과 어긋나도 테스트는 초록색이다. 이것이 목으로 격리한 테스트가 통과하는데 통합 시점에 깨지는 전형적인 경로다. 목이 약속한 계약이 실제 구현과 일치하는지는 별도의 통합 테스트나 계약 테스트로만 확인할 수 있다.
준비 코드가 본문보다 길어진다
목 설정이 스무 줄씩 필요하다면 그것은 목의 문제가 아니라 테스트 대상이 너무 많은 것에 의존한다는 신호다. 이 경우 목을 더 잘 쓰는 방향이 아니라 협력 관계를 줄이는 방향으로 설계를 고치는 것이 맞다. TDD 개요에서 말한 설계 피드백이 실제로 나타나는 지점이 여기다.
관련 문서
- Classicist vs Mockist - 목을 얼마나 쓸 것인가에 따라 갈리는 두 학파
- TDD 개요 - TDD의 정의와 설계 피드백
- 테스트 피라미드와 단위의 범위 - 더블로 격리할지 실제 객체를 쓸지 판단하는 solitary/sociable 구분
- Red-Green-Refactor - 사이클 각 단계의 규칙
- Repository와 Service - Fake로 대체되는 대표적 경계
참고 자료
- Gerard Meszaros, xUnit Test Patterns: Refactoring Test Code (2007) - 테스트 더블 분류의 원전
- Martin Fowler, Mocks Aren’t Stubs
- Martin Fowler, Test Double
- Mockito 공식 문서