디자인 패턴 테스트 코드, Mock과 Stub과 Spy는 언제 갈라 쓰는가
전략·옵저버·데코레이터 패턴을 예로 Mock·Stub·Spy 테스트 더블을 언제 골라 쓰는지 JUnit5·Mockito 코드로 정리한다
2026-08-13 · 최초 발행 2025-10-14
디자인 패턴으로 짠 코드를 테스트할 때 가장 먼저 부딪히는 질문은 "이 객체를 어떻게 가짜로 만들 것인가"가 아니라 "이 협력 관계에서 무엇을 검증해야 하는가"다. 패턴은 객체 간 역할과 협력 구조를 정의하므로, 테스트도 결과값보다 그 협력이 계약대로 일어났는지를 확인하는 데 초점이 맞아야 한다.
패턴마다 검증 대상이 다르다
테스트가 보장해야 할 것은 패턴이 의도한 협력 구조와 계약이다. 이를 위해 상태 기반 검증(출력·상태 비교)과 행위 기반 검증(호출·상호작용 확인)을 상황에 맞게 병행하고, 테스트 더블(Test Double)로 외부 의존성을 가짜 구현으로 대체해 결정적 실행을 보장한다.
테스트 더블은 역할에 따라 나뉜다.
- Stub: 입력에 대해 고정된 출력을 반환한다. 조회성(쿼리) 의존성을 대체하는 데 적합하고, 상태 기반 검증에 우선 적용한다.
- Mock: 기대한 호출·인자·호출 수를 검증한다. 행위 기반 검증에 적합하지만, 과도한 상호작용 검증은 테스트를 취약하게 만들 수 있다.
- Spy: 실제 객체를 감시하며 호출 여부·인자를 기록한다. 부분 행위 검증과 간단한 위임 확인에 적합하고, 과도한 부분 모킹은 권장하지 않는다.
- Fake(간이 인메모리 구현)와 Dummy(채우기용 인자)는 상황에 따라 병행한다.
생성·행위·구조 패턴별로 초점이 갈린다
패턴 범주에 따라 테스트가 확인해야 할 지점도 달라진다. Strategy·Observer·Template Method·Command 같은 행위(Behavioral) 패턴은 행위 검증이 중심이라 Mock·Spy를 우선 활용한다. Decorator·Proxy·Adapter 같은 구조(Structural) 패턴은 위임·호출 체인을 확인하는 게 핵심이라 Spy로 위임을 확인하고 네트워크·IO 같은 경계는 Stub·Fake로 차단한다. Factory Method·Abstract Factory·Builder·Singleton 같은 생성(Creational) 패턴은 생성 결과의 계약을 검증하는 것이 목적이라 Stub으로 환경을 고정하고 테스트 데이터 빌더를 활용한다.
인터페이스로 삽입 지점을 만든다
패턴의 역할(Role)을 인터페이스로 모델링해두면 테스트 더블을 끼워 넣을 지점이 생긴다. 생성자 주입이나 팩토리 주입으로 의존성을 넘기면 테스트 격리가 쉬워진다. 계산·변환 로직은 상태 기반(Stub + Assert)을 선호하고, 콜백·이벤트·위임 로직은 행위 기반(Mock/Spy + Verify)을 적용하는 식으로 로직 성격에 따라 검증 전략을 나눈다.
결정적 실행과 픽스처
FakeClock, 시드가 고정된 난수, 인메모리 저장소(Fake)로 비결정성을 제거하고, 네트워크·파일·스레드 같은 경계는 Stub·Fake로 대체한다. Test Data Builder나 Object Mother 패턴으로 픽스처 중복을 줄이고, Given-When-Then(Arrange-Act-Assert) 템플릿을 일관되게 적용한다. 예외·재시도·타임아웃·서킷 브레이커 같은 실패 흐름은 Stub으로 예외를 주입해 확인하고, 트랜잭션 경계나 재시도 횟수·백오프 정책은 Mock·Spy로 상호작용을 검증한다.
선택 절차를 도식으로 보면
패턴 유형과 상호작용 성격이 정해지면 아래 절차로 테스트 더블 종류와 검증 방식을 고를 수 있다.
Mock·Stub·Spy를 저울질하는 기준
| 항목 | Stub | Mock | Spy |
|---|---|---|---|
| 성능 | 매우 빠름(상수 시간) | 매우 빠름 | 빠름(실객체 호출 비용 포함) |
| 확장성 | 테스트 시나리오 추가 용이 | 상호작용 스펙 증가 시 유지비 상승 | 부분 감시 범위 확장 시 복잡도 증가 |
| 일관성 | 높음(고정 출력) | 중간(오버스펙 검증 시 취약) | 중간(실행 경로 의존) |
| 안정성 | 높음(부작용 적음) | 중간(호출 순서·횟수에 민감) | 중간(부분 모킹 오남용 리스크) |
| 운영 편의 | 간단한 설정 | 프레임워크 의존 높음 | 실객체/프록시 구성 필요 |
실무에서 이렇게 갈라 쓴다
Strategy 패턴에서는 컨텍스트가 선택된 전략에 위임하는지가 관건이다. Spy로 전략 호출·인자를 검증하고, 외부 조회는 Stub으로 기준 값을 고정해 결정화한다.
Observer 패턴에서는 구독자에게 정확한 이벤트가 통지되는지, 호출 횟수·순서가 맞는지가 검증 대상이다. Mock으로 update 호출·인자·횟수를 확인하고, 특정 구독자가 예외를 던졌을 때 나머지 구독자 처리가 보장되는지도 시나리오에 포함한다.
Decorator·Proxy 패턴에서는 데코레이터가 부가 기능을 수행한 뒤 위임하는지, 원격 호출을 얼마나 줄이는지가 초점이다. Spy로 내부 위임을 검증하고 Remote Client 같은 원격 의존성은 Stub·Fake로 대체한다.
코드로 보면: Strategy에 Spy와 Stub을 섞기
전제조건은 Java 17+, Gradle 7+, JUnit 5.10+, Mockito 5+다. gradle.build에는 다음이 필요하다.
- testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
- testImplementation 'org.mockito:mockito-core:5.11.0'
- testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
도메인·패턴 코드는 다음과 같다.
// src/main/java/example/Strategy.java
package example;
public interface Strategy { int apply(int base); }
// src/main/java/example/DoubleStrategy.java
package example;
public class DoubleStrategy implements Strategy {
@Override public int apply(int base) { return base * 2; }
}
// src/main/java/example/StrategySelector.java
package example;
public interface StrategySelector { Strategy select(User u); }
// src/main/java/example/RateRepository.java
package example;
public interface RateRepository { int base(); }
// src/main/java/example/User.java
package example;
public record User(boolean vip) {}
// src/main/java/example/PricingService.java
package example;
public class PricingService {
private final StrategySelector selector;
private final RateRepository repo;
public PricingService(StrategySelector selector, RateRepository repo) {
this.selector = selector; this.repo = repo;
}
public int price(User u) {
int base = repo.base();
Strategy s = selector.select(u);
return s.apply(base);
}
}
테스트 코드는 Stub(repo)으로 조회값을 결정화하고, Spy(strategy)로 실제 로직을 유지한 채 호출을 검증하고, Mock(selector)으로 선택 규칙을 테스트 범위에서 고정한다.
// src/test/java/example/PricingServiceTest.java
package example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PricingServiceTest {
@Test
void 전략_위임_Spy_검증_및_조회_Stub_고정() {
// Arrange
RateRepository repo = mock(RateRepository.class); // Stub 역할
when(repo.base()).thenReturn(100);
DoubleStrategy real = new DoubleStrategy();
Strategy spyStrategy = spy(real); // Spy
StrategySelector selector = mock(StrategySelector.class); // Stub-like for selection
when(selector.select(new User(true))).thenReturn(spyStrategy);
PricingService svc = new PricingService(selector, repo);
// Act
int result = svc.price(new User(true));
// Assert(상태)
assertEquals(200, result);
// Verify(행위)
verify(repo, times(1)).base();
verify(selector, times(1)).select(new User(true));
verify(spyStrategy, times(1)).apply(100);
verifyNoMoreInteractions(repo, selector, spyStrategy);
}
}
코드로 보면: Observer에 Mock 적용하기
Observer 도메인 코드는 Subject가 등록된 Observer 전원에게 이벤트를 통지하는 구조다.
// src/main/java/example/Event.java
package example;
public record Event(String type, String payload) {}
// src/main/java/example/Observer.java
package example;
public interface Observer { void update(Event e); }
// src/main/java/example/Subject.java
package example;
import java.util.ArrayList;
import java.util.List;
public class Subject {
private final List<Observer> observers = new ArrayList<>();
public void register(Observer o) { observers.add(o); }
public void notifyAll(Event e) {
for (Observer o : observers) o.update(e);
}
}
Mock(Observer)은 호출 횟수·인자 검증에 최적이다.
// src/test/java/example/ObserverTest.java
package example;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
class ObserverTest {
@Test
void 구독자에게_이벤트_통지_Mock_검증() {
// Arrange
Subject subject = new Subject();
Observer subscriberA = mock(Observer.class);
Observer subscriberB = mock(Observer.class);
subject.register(subscriberA);
subject.register(subscriberB);
Event event = new Event("ORDER_PAID", "orderId=123");
// Act
subject.notifyAll(event);
// Verify
verify(subscriberA, times(1)).update(event);
verify(subscriberB, times(1)).update(event);
verifyNoMoreInteractions(subscriberA, subscriberB);
}
}
실패 시나리오도 함께 다뤄야 한다. 특정 구독자가 예외를 던졌을 때 나머지 구독자 처리가 보장돼야 한다면, Stub·Mock에 thenThrow로 예외를 주입해 전파·격리 정책을 검증한다.
AAA 절차와 트랜잭션·동시성 검증
Arrange 단계에서는 인터페이스 기반 의존성을 분리해 테스트 더블을 주입한다. Stub에는 입력에 따른 출력이나 예외·지연을 주입하고, Spy·Mock은 감시·검증 대상을 최소 검증 원칙에 맞춰 명확히 한다. Act 단계에서는 단일 퍼블릭 API를 호출하고 외부 부작용을 차단하며, 시간·랜덤·IO는 Fake로 대체한다. Assert·Verify 단계에서는 상태 검증을 우선하고 필요한 최소한의 행위 검증만 추가하며, 호출 순서·정확한 인자 매칭을 과도하게 요구하는 과사양(overspecification)은 피한다.
트랜잭션 경계를 테스트할 때는 Stub으로 실패·롤백을 유도하고 Mock으로 호출 순서(시맨틱)를 검증한다. 동시성 흐름은 가상 스케줄러나 가짜 실행기(Fake Executor)로 결정화한 뒤 Spy로 실행 횟수를 검증하고, 재시도·백오프는 Stub 시간원천(FakeClock)과 Mock의 재호출 횟수 검증을 조합한다.
모범사례와 흔한 안티패턴
상태 검증을 우선하고 행위 검증은 보완적으로 쓰는 원칙, 인터페이스 기반 설계와 생성자 주입으로 테스트를 격리하는 방식, Test Data Builder·FakeClock 도입으로 결정성을 확보하는 방식이 모범사례다. Adapter·Repository 경계는 계약 테스트(Contract Test)로 품질을 보증하고, 실패·예외·타임아웃 경로는 Stub 예외 주입으로 먼저 커버한다. 외부 IO를 Stub·Fake로 대체하면 단위 테스트 실행 시간을 70% 이상 단축할 수 있고, 행위 검증을 병행하면 뮤테이션 스코어가 10~20%p 오른다. 비결정성을 걷어내면 플레이키 테스트로 인한 재시행 실패율도 1% 밑으로 내려간다.
반대로 호출 순서·세부 인자에 집착하는 과도한 Mock 검증은 테스트 취약성을 키우고, Spy를 남용해 구현 세부에 결합하면 리팩터링 저항이 커진다. 구체 클래스를 직접 모킹하면 내부 변경에 취약해지므로 인터페이스 추출이 필요하고, 전역 싱글톤이나 정적 호출은 테스트 더블을 끼워 넣기 어려우니 팩토리나 래퍼 도입을 고려해야 한다.