테스트 스텁으로 의존성을 분리하는 단위 테스트 설계
테스트 스텁의 역할과 다른 테스트 더블의 차이, 의존성 주입 기반 구현 방식, 외부 API·시간·마이크로서비스 환경에서의 활용 원칙을 정리한다.
2026-08-14 · 최초 발행 2025-05-23
의존성을 끊고 테스트 대상만 확인하는 방법
테스트 스텁(Test Stub)은 테스트 대상 모듈이 호출하는 다른 모듈, 변수, 객체를 임시 구현으로 바꾸는 소프트웨어 구성 요소다. 복잡한 의존성을 단순한 응답으로 대체해, 테스트 대상만 분리된 환경에서 확인하게 한다.
테스트 더블(Test Double)은 실제 객체 대신 테스트에서 사용하는 가짜 객체 전반을 가리키며, 스텁은 그중 하나다. 아직 개발되지 않은 컴포넌트에 의존하거나, 실행이 어렵고 오래 걸리는 컴포넌트를 다뤄야 할 때 특히 유용하다.
모듈 A가 모듈 B에 의존하더라도 B를 스텁으로 바꾸면 A를 독립적으로 검증할 수 있다. 네트워크, 데이터베이스, 외부 API처럼 통제하기 어려운 요소도 제어 가능한 형태로 전환된다. 그 결과 테스트 실행 속도를 높이고 일관된 결과를 얻을 수 있다.
스텁은 호출을 검증하지 않는다
테스트 더블은 대체 객체라는 공통점이 있지만, 무엇을 확인하는지에 따라 역할이 다르다.
스텁은 호출에 대해 하드코딩된 응답을 제공하지만, 그 호출 자체의 행동을 검증하지는 않는다. 반면 목(Mock)은 예상한 호출이 발생했는지 검증하면서 미리 정한 응답도 제공한다. 스파이(Spy)는 실제 객체를 사용하되 호출 정보를 기록한다.
페이크(Fake)는 인메모리 DB처럼 실제 구현을 경량화한 대체물이다. 더미(Dummy)는 인자로 전달되지만 실제 로직에서는 사용되지 않는 객체를 뜻한다.
구현체를 직접 만들어 주입하기
가장 단순한 방식은 의존 인터페이스를 구현한 스텁 클래스를 만들고 테스트 대상에 주입하는 것이다. 아래 스텁은 실제 결제 처리 대신 항상 성공을 반환한다.
// 원래 의존하는 실제 서비스 인터페이스
public interface PaymentGateway {
boolean processPayment(double amount);
}
// 테스트를 위한 스텁 구현
public class PaymentGatewayStub implements PaymentGateway {
@Override
public boolean processPayment(double amount) {
// 항상 성공 반환 (실제 지불 처리 로직 대체)
return true;
}
}
// 테스트 코드
@Test
public void testOrderProcessWithPayment() {
// 스텁 객체 생성
PaymentGateway stubGateway = new PaymentGatewayStub();
// 테스트 대상 객체에 스텁 주입
OrderProcessor processor = new OrderProcessor(stubGateway);
// 테스트 실행
boolean result = processor.processOrder(new Order(100.0));
// 결과 검증
assertTrue(result);
}
모킹 프레임워크를 사용하면 별도 구현 클래스를 두지 않고도 같은 방식의 응답을 설정할 수 있다.
@Test
public void testOrderProcessWithMockito() {
// Mockito를 사용한 스텁 생성
PaymentGateway stubGateway = Mockito.mock(PaymentGateway.class);
// 스텁 동작 정의
Mockito.when(stubGateway.processPayment(Mockito.anyDouble())).thenReturn(true);
// 테스트 대상 객체에 스텁 주입
OrderProcessor processor = new OrderProcessor(stubGateway);
// 테스트 실행
boolean result = processor.processOrder(new Order(100.0));
// 결과 검증
assertTrue(result);
}
두 방식 모두 핵심은 같다. 테스트 대상이 구체 구현이 아니라 교체 가능한 의존성에 연결되도록 만들고, 테스트마다 필요한 응답을 제공하는 것이다.
데이터베이스 접근을 스텁으로 대체한 예
사용자 서비스가 리포지토리를 통해 데이터베이스에 접근한다면 실제 DB 연결은 느리고 환경에 따라 달라질 수 있다. 서비스 로직을 검증하는 테스트에서는 리포지토리를 스텁으로 바꾸는 편이 적합하다.
실제 구현은 데이터베이스 연결과 조회 결과 매핑을 포함한다.
public class UserService {
private UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public User findUserById(long id) {
return repository.findById(id);
}
public boolean isUserPremium(long id) {
User user = repository.findById(id);
return user != null && user.isPremium();
}
}
// 실제 DB에 접근하는 리포지토리
public class UserRepositoryImpl implements UserRepository {
@Override
public User findById(long id) {
// 실제 데이터베이스에서 사용자 조회
Connection conn = DatabaseConnection.getConnection();
// SQL 실행 및 결과 매핑...
return user;
}
}
테스트에서는 리포지토리가 항상 프리미엄 사용자를 반환하도록 구성해 UserService의 판단만 확인할 수 있다.
@Test
public void testIsUserPremium_WithPremiumUser() {
// 테스트용 스텁 생성
UserRepository stubRepository = new UserRepositoryStub();
// 서비스에 스텁 주입
UserService service = new UserService(stubRepository);
// 테스트 실행
boolean isPremium = service.isUserPremium(1L);
// 결과 검증
assertTrue(isPremium);
}
// 스텁 구현
class UserRepositoryStub implements UserRepository {
@Override
public User findById(long id) {
// 항상 프리미엄 사용자 반환
User stubUser = new User(id, "Test User", true);
return stubUser;
}
}
테스트 목적에 맞는 응답만 남긴다
스텁은 가능한 단순해야 한다. 테스트 대상과 무관한 동작까지 구현하면 테스트 코드 자체가 복잡해진다. 같은 입력에는 같은 결과를 반환하도록 하고, 실제 의존성에서 테스트 대상을 완전히 분리해야 한다.
다른 개발자가 읽었을 때 어떤 조건을 재현하는 스텁인지 이해할 수 있어야 한다. 인터페이스 기반으로 의존성을 추상화하고 생성자나 세터 주입을 활용하면, 테스트마다 스텁을 바꾸기 쉽다.
정상 흐름뿐 아니라 경계값과 예외 상황도 스텁 응답으로 만들 수 있다. 공통으로 쓰이는 스텁은 라이브러리로 관리해 중복을 줄일 수 있으며, 스텁을 사용한 뒤에는 호출 여부보다 테스트 대상의 상태(state)를 확인하는 방식이 적합하다.
외부 응답과 시간을 고정하는 테스트
외부 결제 게이트웨이는 성공과 실패 응답을 통제하기 어렵다. 스텁은 테스트에 필요한 응답 시나리오를 선택할 수 있게 한다.
// 결제 API 스텁
public class PaymentGatewayStub implements PaymentGateway {
private boolean shouldSucceed;
public PaymentGatewayStub(boolean shouldSucceed) {
this.shouldSucceed = shouldSucceed;
}
@Override
public PaymentResult processPayment(PaymentRequest request) {
if (shouldSucceed) {
return new PaymentResult(true, "Payment successful", "TX123456");
} else {
return new PaymentResult(false, "Insufficient funds", null);
}
}
}
시간에 따라 결과가 달라지는 코드도 같은 방식으로 다룰 수 있다. 시간 제공자를 인터페이스로 분리하고 고정 시간을 반환하는 구현을 주입하면, 업무 시간 안팎의 조건을 재현할 수 있다.
public interface TimeProvider {
LocalDateTime getCurrentTime();
}
// 테스트용 시간 제공자 스텁
public class FixedTimeProvider implements TimeProvider {
private final LocalDateTime fixedTime;
public FixedTimeProvider(LocalDateTime fixedTime) {
this.fixedTime = fixedTime;
}
@Override
public LocalDateTime getCurrentTime() {
return fixedTime;
}
}
// 테스트 코드
@Test
public void testBusinessHoursCheck() {
// 업무 시간 내 고정 시간 스텁
TimeProvider businessHoursStub = new FixedTimeProvider(
LocalDateTime.of(2023, 5, 10, 14, 0)); // 오후 2시
BusinessService service = new BusinessService(businessHoursStub);
assertTrue(service.isDuringBusinessHours());
// 업무 시간 외 고정 시간 스텁
TimeProvider afterHoursStub = new FixedTimeProvider(
LocalDateTime.of(2023, 5, 10, 22, 0)); // 오후 10시
service = new BusinessService(afterHoursStub);
assertFalse(service.isDuringBusinessHours());
}
마이크로서비스 테스트에서의 경계
마이크로서비스 환경에서는 주문 서비스가 결제, 재고, 배송 서비스에 의존할 수 있다. 테스트 환경에서 이 의존 서비스를 스텁으로 교체하면 다른 팀의 서비스 완성을 기다리지 않고 개발과 테스트를 진행할 수 있다.
이 구조는 서비스 간 API 계약 준수 여부를 확인하고, 다른 서비스의 장애 상황을 시뮬레이션하는 데 쓰인다. 의존 서비스의 지연 없이 부하 테스트를 수행할 수도 있다.
다만 스텁은 실제 구현과 동작이 다를 수 있다. 인터페이스가 바뀌면 스텁도 함께 수정해야 하며, 스텁을 과도하게 사용하면 테스트 코드가 복잡해진다. 컴포넌트 사이의 실제 상호작용 문제 역시 스텁만으로는 발견하기 어렵다. 격리 테스트의 빠른 피드백과 실제 의존성을 포함한 검증을 함께 가져가야 하는 이유다.