테스트 더블을 선택하는 기준: Mock·Stub·Spy·Fake
Mock, Stub, Spy, Fake의 역할과 선택 기준을 정리하고, Jest 기반 테스트에서 의존성 격리와 검증 전략을 설계하는 방법을 다룬다.
2026-08-14 · 최초 발행 2025-12-23
외부 의존성을 테스트 경계 밖으로 밀어내기
단위 테스트가 IO, 네트워크, 시간, 스레드, 난수 같은 외부 조건에 묶이면 실행 속도와 재현성이 함께 떨어진다. 테스트 더블(Test Doubles)은 실제 의존성을 테스트 친화적인 구현으로 바꿔, 테스트를 격리하고 실패 원인을 좁히는 기법이다.
테스트 더블은 역할에 따라 구분한다.
- Stub은 고정된 응답을 제공하며, 결과값이나 상태 변화 확인에 주로 쓴다.
- Mock은 호출 여부·횟수·순서·인자처럼 상호작용 자체를 검증한다.
- Spy는 실제 또는 부분 구현의 호출 기록을 수집한 뒤 사후에 검증한다.
- Fake는 인메모리 DB나 간이 메시징처럼 실제 동작에 가까운 테스트용 구현이다.
보통 의존성 주입(DI), 팩토리 또는 구성 모듈을 통해 SUT(System Under Test)에 더블을 주입한다. 테스트는 Given-When-Then, 즉 Arrange-Act-Assert 흐름에서 Arrange 단계에 더블을 연결하고, Act로 대상 동작을 실행한 뒤 Assert에서 결과를 확인한다.
검증하려는 대상에 따라 더블을 고른다
외부 시스템은 HTTP, DB, 파일, 시간, 랜덤 값처럼 경계가 분명한 곳에서 격리한다. 이 경계를 분리하면 실패 원인을 국소화할 수 있고, SUT와 의존성의 책임도 더 선명해진다.
결과값과 상태 변화를 확인하는 상태 기반 검증에는 Stub과 Fake가 어울린다. 반면 호출 여부, 호출 순서, 인자 전달처럼 협력 객체 간의 약속을 확인해야 한다면 Mock이나 Spy가 적합하다. 타임아웃, 예외, 네트워크 오류 같은 비정상 경로도 더블로 재현해 회복력과 리트라이 로직을 검증할 수 있다.
테스트마다 더블을 초기화하거나 리셋하고, 고정 시드와 가짜 시계(Fake Clock)를 적용해야 테스트 간 독립성을 지킬 수 있다. JavaScript의 Jest, Java의 Mockito, Python의 pytest + unittest.mock은 이런 작업을 위한 표준화된 API를 제공한다.
상태 검증과 상호작용 검증의 분기
유형별 특성과 유지 비용
| 더블 유형 | 성능(속도) | 확장성(재사용) | 일관성(결정성) | 안정성(플레이키 감소) | 운영 편의 |
|---|---|---|---|---|---|
| Stub | 매우 높음 | 보통 | 높음 | 높음 | 매우 높음 |
| Mock | 높음 | 보통 | 높음 | 중간(기대 과민) | 높음 |
| Spy | 높음 | 보통 | 중간 | 중간 | 보통 |
| Fake | 높음 | 높음(공용 테스트 유틸) | 매우 높음 | 매우 높음 | 보통(구현 필요) |
Stub과 Fake는 상태 검증과 실패 시뮬레이션에 강점이 있다. Mock과 Spy는 프로토콜이나 계약 검증에 유용하다. Fake는 처음 구현할 비용이 들지만, 반복해서 활용하면 총비용을 낮출 수 있다.
리포지토리·API·시간 의존성 다루기
리포지토리 계층은 인메모리 FakeRepository로 대체해 CRUD, 트랜잭션 경계, 유니크 제약 조건을 시뮬레이션할 수 있다. 통합 테스트로 넘어가기 전에 도메인 규칙을 빠르게 검증하는 방식이다.
외부 API 통신에서는 Mock으로 HTTP 클라이언트의 호출 횟수, 백오프, 재시도 정책을 확인한다. 장애나 지연은 Stub으로 재현해 서킷 브레이커와 타임아웃 로직을 검증한다.
이벤트와 콜백은 Spy로 리스너 등록, 호출 순서, 페이로드를 관찰할 수 있다. 실제 핸들러 동작을 유지하면서 호출만 확인해야 할 때 적합하다. 시간과 난수 의존성은 Fake Clock과 고정 시드(Random)로 대체해 일자 경계 조건과 만료 로직을 결정적으로 테스트한다.
레거시 코드에서는 외부 의존성 주변에 어댑터와 Fake 또는 Stub을 두어 점진적인 리팩터링과 회귀 방지에 활용할 수 있다.
Jest에서 각 더블을 쓰는 방식
아래 예시는 Node.js 18+와 Jest 29+를 가정한다.
Stub은 고정된 응답을 제공한다.
// SUT
function priceWithTax(calc, amount) {
return calc.tax(amount) + amount;
}
// Stub
const stubCalc = { tax: (amount) => 10 }; // 고정 세금
test('Stub: 고정 세금 적용', () => {
expect(priceWithTax(stubCalc, 100)).toBe(110);
});
Mock은 의도한 상호작용이 일어났는지 확인한다.
function fetchUser(api, id) {
return api.get(`/users/${id}`);
}
test('Mock: 정확한 경로로 1회 호출', async () => {
const api = { get: jest.fn().mockResolvedValue({ id: 1 }) };
await fetchUser(api, 1);
expect(api.get).toHaveBeenCalledTimes(1);
expect(api.get).toHaveBeenCalledWith('/users/1');
});
Spy는 부분 더블로서 호출 기록을 남긴다.
const logger = { info: (...args) => console.log(...args) };
function process(job) {
logger.info('start', job.id);
// ...
}
test('Spy: 로그 호출 확인', () => {
const spy = jest.spyOn(logger, 'info').mockImplementation(() => {});
process({ id: 'J1' });
expect(spy).toHaveBeenCalledWith('start', 'J1');
spy.mockRestore();
});
Fake는 실제 사용 흐름에 가까운 간이 구현을 제공한다.
class UserRepoFake {
constructor() { this.store = new Map(); }
async save(u) {
if (this.store.has(u.id)) throw new Error('duplicate');
this.store.set(u.id, u);
return u;
}
async find(id) { return this.store.get(id) ?? null; }
}
// SUT: 서비스
async function register(repo, user) {
await repo.save(user);
return repo.find(user.id);
}
test('Fake: 중복 저장 차단 및 조회 성공', async () => {
const repo = new UserRepoFake();
const u = { id: 'u1', name: 'kim' };
await expect(register(repo, u)).resolves.toEqual(u);
await expect(repo.save(u)).rejects.toThrow('duplicate');
});
의존성 식별부터 공용 유틸 관리까지
도입할 때는 먼저 IO, 시간, 네트워크, 외부 서비스처럼 대체할 의존성을 목록화한다. 이어 상태 기반 검증인지 상호작용 기반 검증인지 정하고, 실제 인터페이스를 유지하면서 실패 경로를 포함한 더블을 설계한다.
주입 방식은 생성자, 팩토리, DI 컨테이너 중 시스템의 구성 방식에 맞춰 정한다. Arrange-Act-Assert 패턴과 테스트 픽스처를 표준화하고, 반복되는 Fake와 빌더는 공용 라이브러리로 관리한다.
다음 항목을 확인할 필요가 있다.
- 실제 계약 시그니처와 동등성을 유지하는가
- 실패, 예외, 경계 시나리오를 충분히 다루는가
- 리셋, 고정 시드, 가짜 시계를 통해 테스트 간 독립성을 보장하는가
Mock을 과도하게 쓰지 않는 이유
소유하지 않은 외부 라이브러리의 내부 구현 세부사항을 Mock으로 묶기보다 공개 계약을 기준으로 검증하는 편이 낫다. 상호작용이 복잡해질수록 Fake가 테스트를 단순하게 만들고 가독성과 안정성을 높일 수 있다. 테스트 이름에는 Stub으로 성공 경로를 검증하는지, Mock으로 재시도 정책을 확인하는지처럼 의도를 드러낸다.
Mock을 과도하게 사용하면 구현 변경에 민감해져 테스트 취성이 커지고 리팩터링 저항성이 생긴다. Spy의 부분 모킹은 실제 동작을 가릴 수 있으므로 관찰 목적이 분명해야 한다. Fake는 초기 비용이 증가할 수 있지만 팀 공용 유틸로 전환하면 총비용을 절감할 수 있다.
원격 IO 제거를 가정하면 테스트 시간을 10배 단축할 수 있고, 플레이키 테스트 비율이 50% 이상 감소한 사례도 빈번하다. 실패 재현성이 높아지면 MTTR이 감소하고 커버리지와 분기 커버도 증가한다. 더블을 제대로 설계하면 응집도 증가와 결합도 감소를 촉진하며, 리팩터링 안전망과 테스트 문서화, 온보딩 효율 향상에도 기여한다.