테스트 더블 정리 (Dummy, Fake, Stub, Spy, Mock)

2026. 5. 26. 20:37·dev/Spring

TL;DR
테스트 더블(Test Double)은 특정한 프레임워크나 도구에 종속된 기술이 아니라,
"외부 의존성으로 인해 테스트가 어려운 상황을 해결하기 위해 진짜 객체 대신 사용하는 모든 대체재" 다.
Java에서 테스트코드 작성시 Mockito를 주로 사용하는데, 대부분의 테스트 더블을 mock() 기반으로 표현할 수 있다.
문제는 같은 mock() 객체라도 어떤 테스트는 반환값 제어에 집중하고,
어떤 테스트는 호출 여부 검증에 집중하며, 어떤 테스트는 둘 다 사용한다는 점이다.
하지만 코드만 봐서는 그 mock 객체가 어떤 의도의 테스트 더블인지 드러나지 않는 경우가 많다.


들어가며

회원 도메인을 개발하면서 UserService 단위 테스트 코드를 작성하던 중 UserRepository를 mock()으로 만들어 사용했다. 어떤 테스트에서는 when().thenReturn()만 사용했고, 어떤 테스트에서는 verify()만 사용했고, 어떤 테스트에서는 둘 다 사용했다. 모두 같은 mock() 객체인데 테스트 더블 관점에서는 각각 Stub, Spy, Mock에 가까운 역할을 하고 있었다. 하지만 코드만 봐서는 그 테스트가 어떤 의도로 작성되었는지 한눈에 드러나지 않는다.


Test Double이 뭔데 — Fowler의 정리

2006년에 Martin Fowler가 Test Double 글에서 Gerard Meszaros의 분류를 짧게 정리해두었다.

Test Double — 테스트를 위해 실제 객체 자리에 끼워 넣는 모든 가짜의 통칭.
영화에서 스턴트 더블이 배우 대신 위험한 장면을 찍는 것에서 따왔다.

  • Dummy — 그냥 자리를 채우려고 넘기는 객체. 실제로는 잘 안 쓰임.
  • Fake — 진짜로 동작하는 가벼운 구현체. 단, 운영용으로는 못 씀 (예: InMemoryUserRepository).
  • Stub — 정해진 응답을 돌려주는 가짜. 테스트가 기대하는 상황을 만들어주는 역할.
  • Spy — "Spies are stubs that also record some information based on how they were called." 즉, Spy는 Stub의 확장이다. 응답도 돌려주면서 호출 내역까지 기록해둔다.
  • Mock — 어떤 호출이 와야 하는지를 미리 기대(expectation)로 박아두고, 검증 시점에 그 기대가 충족됐는지 확인한다. 행위 검증(behavior verification)이 목적.

Spy가 조금 헷갈리는데, Stub의 확장이라는 말이 핵심이다. 응답을 통제한다는 점에선 Stub과 같고, 다만 호출 내역을 볼 수 있다는 차이가 있다.

아래 예시들은 모두 EmailSender가 void send(String email) 시그니처를 가진다고 가정한다.

// Stub — 그냥 응답만 통제. 호출 여부는 관심 없음
// 별도 when() 없이도 mock은 void 메서드를 그냥 받아준다
EmailSender stub = mock(EmailSender.class);

// Spy — 응답이 아니라 호출 내역을 확인
EmailSender spy = mock(EmailSender.class);
userService.signUp("welcome@test.com");

ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(spy).send(captor.capture());
assertThat(captor.getValue()).isEqualTo("welcome@test.com");

// Mock — 호출이 일어났는지/아닌지를 검증
EmailSender mock = mock(EmailSender.class);
userService.signUp("user@test.com");

verify(mock).send("user@test.com");           // 사용자에게는 전송
verify(mock, never()).send("admin@test.com"); // 관리자에게는 전송 안 됨

Dummy — 자리만 채우는 객체

UserService 생성자가 EmailSender를 받는데, 이 테스트는 이메일과 무관한 로직만 검증한다. 그냥 null이면 NPE가 나니까 자리만 채우는 객체를 넘긴다.

EmailSender dummy = mock(EmailSender.class);  // 호출되지 않을 거니까 아무거나
UserService userService = new UserService(userRepository, dummy);

userService.findById(1L);

dummy는 만들어두지만 실제로 호출되지 않는다. findById는 이메일 발송 로직을 타지 않기 때문에 dummy.send()는 한 번도 호출되지 않는다. (생성자 파라미터 자리를 채우려고 객체만 잡아주는 역할)


Fake — 진짜로 동작하는 가벼운 구현체

class InMemoryUserRepository implements UserRepository {
    private final Map<Long, User> store = new HashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    @Override
    public User save(User user) {
        Long id = sequence.incrementAndGet();
        store.put(id, user);
        return user;
    }

    @Override
    public Optional<User> findByLoginId(String loginId) {
        return store.values().stream()
            .filter(u -> u.getLoginId().equals(loginId))
            .findFirst();
    }

    @Override
    public boolean existsByLoginId(String loginId) {
        return findByLoginId(loginId).isPresent();
    }
}
@Test
void 가입한_사용자는_로그인할_수_있다() {
    UserRepository fake = new InMemoryUserRepository();
    UserService userService = new UserService(fake, passwordEncoder);

    userService.signUp(signUpCommand("홍길동", RAW_PASSWORD));

    Optional<Long> result = userService.authenticate("홍길동", RAW_PASSWORD);
    assertThat(result).isPresent();
}

Stub — 반환값만 통제하는 경우

when(userRepository.findByLoginId("testUser")).thenReturn(Optional.of(user));

Optional<Long> result = userService.authenticate("testUser", RAW_PASSWORD);

assertThat(result).contains(user.getId());

findByLoginId를 누가 호출했는지, 몇 번 호출했는지에는 관심이 없다. 이 ID로 찾으면 이 유저가 나오는 mock을 만들어두고 userService.authenticate()가 올바르게 동작하는지만 검증한다.


Mock — 호출이 일어났는지를 검증하는 경우

when(userRepository.existsByLoginId("testUser")).thenReturn(true);

assertThatThrownBy(() -> userService.signUp(signUpCommand()))
    .isInstanceOf(CoreException.class);

verify(userRepository, never()).save(any());

이미 가입된 ID이므로 save가 호출되지 않아야 한다는 게 핵심이다. existsByLoginId의 반환값은 그 상황을 만들기 위한 트리거일 뿐이고, 검증하고 싶은 건 save라는 행위가 일어났는지이다. 위 소스에서 when().thenReturn()은 "이미 가입된 사용자"를 만들기 위한 Stub 이고, verify(..., never())는 save 행위가 일어나면 안 된다는 Mock 의도다.


Spy — 실제 객체의 데이터를 확인이 필요한 경우

ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());

User saved = captor.getValue();
assertThat(passwordEncoder.matches(RAW_PASSWORD, saved.getPassword())).isTrue();

save가 호출되었는지는 위와 같은데, 여기선 save에 어떤 User가 들어갔는지를 확인해서 비밀번호가 진짜 암호화 되었는지 확인해서 비교한다.


Mockito.spy()와 Fowler의 Test Spy는 다르다

여기서 헷갈리기 쉬운 점이 하나 있다.

Mockito의 spy()와 Fowler가 말하는 Test Spy는 이름은 같지만 관점이 다르다.

  • Test Spy (Fowler) → 호출 정보를 기록하고 검증하기 위한 테스트 더블의 역할(의도)
  • Mockito.spy() → 실제 객체를 wrapping해서 일부 동작만 가로채는 partial mocking 구현 기법

즉 Fowler의 Spy는 "왜 사용하는가"에 대한 개념이고, Mockito.spy()는 "어떻게 구현하는가"에 대한 API다.

그래서 Mockito에서 spy()를 사용하지 않더라도, 호출 기록과 검증을 수행한다면 Fowler 의미의 Spy 역할을 하고 있다고 볼 수 있다. 실무에서는 보통 mock() + verify() + ArgumentCaptor 조합으로 Spy 역할을 구현하는 경우가 많다.

Mockito는 구현 도구이고, Fowler의 테스트 더블 분류는 테스트 설계 관점이다.

Fowler 의미의 Test Spy

interface EmailSender {
    void send(String email);
}

class EmailSenderSpy implements EmailSender {
    boolean called = false;
    String capturedEmail;

    @Override
    public void send(String email) {
        called = true;
        capturedEmail = email;
    }
}

@Test
void 회원가입시_이메일을_발송한다() {
    EmailSenderSpy spy = new EmailSenderSpy();

    UserService userService = new UserService(spy);

    userService.signUp("test@test.com");

    assertTrue(spy.called);
    assertEquals("test@test.com", spy.capturedEmail);
}

이 객체는 호출 여부와 전달된 인자를 직접 기록한다. 즉 Fowler가 말하는 Test Spy의 전형적인 형태다.

Mockito mock + verify + captor

@Test
void 회원가입시_이메일을_발송한다() {
    EmailSender emailSender = mock(EmailSender.class);

    UserService userService = new UserService(emailSender);
    userService.signUp("test@test.com");

    ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);

    verify(emailSender).send(captor.capture());
    assertEquals("test@test.com", captor.getValue());
}

Mockito.spy()를 사용하지 않았지만, 호출 정보와 전달 인자를 검증하고 있으므로 Fowler 의미에서는 Spy 역할을 수행하고 있다. verify로 호출을 확인하고 ArgumentCaptor로 인자를 꺼낸다 — Fowler 분류로 보면 Mock(행위 검증)과 Spy(호출 기록)가 한 테스트 안에 동시에 들어있는 셈이다.

Mockito.spy() (Partial Mock)

class EmailSender {
    public void send(String email) {
        System.out.println("real email send");
    }
}

@Test
void partial_mocking() {
    EmailSender realSender = new EmailSender();
    EmailSender spySender = Mockito.spy(realSender);

    // 일부만 stub — 다른 메서드는 진짜로 동작함
    doNothing().when(spySender).send("blocked@test.com");

    spySender.send("test@test.com");      // 진짜 send 실행 ("real email send" 출력됨)
    spySender.send("blocked@test.com");   // stub 되어 아무것도 안 함

    verify(spySender, times(2)).send(anyString());
}

여기서는 실제 객체를 감싸고 있다. 즉 기존 동작은 유지하면서 일부 메서드만 검증하거나 stub 하는 partial mocking 방식이다. Mockito.spy()의 핵심 목적이다.

이름은 같은 'Spy'지만 Fowler의 Spy(직접 짠 기록 객체)와 Mockito.spy()(실제 객체 부분 모킹)는 다른 개념이다.

저작자표시 (새창열림)

'dev > Spring' 카테고리의 다른 글

Resilience4j CircuitBreaker 슬라이딩 윈도우 동작 원리(COUNT_BASED vs TIME_BASED)  (0) 2026.05.14
[SpringMVC] 요청 매핑, API 요청 매핑  (0) 2022.11.23
스프링 컨테이너와 스프링 빈  (1) 2022.11.05
[Spring] @ResponseBody 어노테이션  (0) 2022.06.07
[Spring] Spring의 컨텍스트?  (0) 2021.07.12
'dev/Spring' 카테고리의 다른 글
  • Resilience4j CircuitBreaker 슬라이딩 윈도우 동작 원리(COUNT_BASED vs TIME_BASED)
  • [SpringMVC] 요청 매핑, API 요청 매핑
  • 스프링 컨테이너와 스프링 빈
  • [Spring] @ResponseBody 어노테이션
:j
:j
ddongjunn@gmail.com
  • :j
    dev.j
    :j
  • 전체
    오늘
    어제
    • :j
      • dev
        • Ceph
        • CS
        • Spring
        • k8s
        • Java
        • JPA
        • Web
        • CCNA
        • 코딩테스트
        • JavaScript
        • XML
        • JSON
        • CSS
        • html
        • jQuery
        • Mssql
        • Oracle
      • 회고
      • :j story
  • 블로그 메뉴

    • 홈
    • 태그
    • github
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    항해99
    항해플러스
    지역변수
    다형성
    ceph
    항해백앤드
    Resilience4J
    appendChild
    HAVING
    Name
    오버라이딩
    오버로딩
    MSSQL
    멤버변수
    group by
    id
    class
    CustomContainer
    Queue
    <br>
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
:j
테스트 더블 정리 (Dummy, Fake, Stub, Spy, Mock)
상단으로

티스토리툴바