테스트 더블

정의

테스트 더블(Test Double)은 테스트에서 실제 협력 객체를 대신하는 모든 대역을 가리키는 총칭이다. 영화의 스턴트 더블(stunt double)에서 온 이름으로, Gerard Meszaros가 xUnit Test Patterns에서 정리했다.

대역이 필요한 이유는 협력 객체가 다음 중 하나에 해당할 때다.

  • 아직 구현되지 않았다
  • 실행이 느리다 (DB, 네트워크)
  • 결과가 매번 다르다 (현재 시각, 랜덤, 외부 시세)
  • 원하는 상태를 만들기 어렵다 (특정 예외, 네트워크 타임아웃)
  • 호출하면 부작용이 있다 (실제 결제, 메일 발송)

일상적으로는 이 모두를 “목(mock)“이라고 뭉뚱그려 부르지만, 다섯 가지는 목적이 서로 다르고 그중 하나만 검증에 관여한다.


다섯 가지 분류

종류역할반환값검증에 사용
Dummy자리만 채운다. 호출되지 않는다없음아니오
Fake단순화된 실제 동작을 한다실제로 계산된 값아니오 (상태로 간접 확인)
Stub미리 정해둔 값을 돌려준다고정된 값아니오
SpyStub이면서 호출 사실을 기록한다고정된 값예 (기록을 나중에 조회)
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 코드판별 기준
Dummymock(Foo.class) (설정도 검증도 없음)아무 설정 없이 넘기기만 함
Stubgiven(...).willReturn(...)반환값만 지정
Spyspy(realObject), ArgumentCaptor실제 동작 + 기록
Mockverify(...)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, DAOFake(인메모리) 또는 Stub
외부 API 클라이언트Stub. 부작용이 요구사항이면 Mock
현재 시각, 랜덤, UUID인터페이스로 감싸 Stub (Clock 주입 등)
메일·SMS·이벤트 발행Mock 또는 Spy

값 객체를 목으로 만드는 것은 대표적인 오용이다. mock(Money.class)처럼 만들면 Money의 실제 계산 규칙이 사라지므로, 테스트는 통과하지만 아무것도 검증하지 못한다.


과용이 만드는 문제

테스트가 구현을 그대로 베낀다

목 설정이 프로덕션 코드의 호출 순서를 한 줄씩 따라가는 상태가 되면, 그 테스트는 코드가 “무엇을 해야 하는가"가 아니라 “어떻게 쓰여 있는가"를 검증한다. 동작을 유지한 리팩터링에도 테스트가 깨지고, 반대로 진짜 버그는 잡지 못한다.

목이 실제와 다르게 동작한다

given(repo.findById(1L)).willReturn(Optional.of(user))로 세운 가정이 실제 구현과 어긋나도 테스트는 초록색이다. 이것이 목으로 격리한 테스트가 통과하는데 통합 시점에 깨지는 전형적인 경로다. 목이 약속한 계약이 실제 구현과 일치하는지는 별도의 통합 테스트나 계약 테스트로만 확인할 수 있다.

준비 코드가 본문보다 길어진다

목 설정이 스무 줄씩 필요하다면 그것은 목의 문제가 아니라 테스트 대상이 너무 많은 것에 의존한다는 신호다. 이 경우 목을 더 잘 쓰는 방향이 아니라 협력 관계를 줄이는 방향으로 설계를 고치는 것이 맞다. TDD 개요에서 말한 설계 피드백이 실제로 나타나는 지점이 여기다.


관련 문서


참고 자료