test 에서 외부 의존 — DB, HTTP, file IO, 시간 — 을 fake 로 대체. "그냥 mock 하면 되지" 는 단순해 보이지만, test double 의 4 종 (stub / mock / spy / fake) 와 각각의 cost 가 다름. 이 가이드는 Gerard Meszaros 의 분류, mock 의 함정, "100% coverage 인데 진짜 안전 안 함" 이 일어나는 이유를 정리한다.
Test Double — 4 종 (Gerard Meszaros)
1. Stub — 미리 정한 값 반환
class UserServiceStub {
findUser(id) {
return {id: 1, name: "Alice"}; // 항상 같은 값
}
}
test("displays user name", () => {
const view = renderUserView(new UserServiceStub());
assert(view.contains("Alice"));
});
용도: "이 시점에 이 값이 와야 한다" 가정하고 다음 로직 검증.
호출 검증 X — return value 만 중요.2. Mock — 호출도 검증
const mockEmail = mock();
processOrder(order, mockEmail);
expect(mockEmail).toHaveBeenCalledWith(
"alice@example.com",
expect.stringContaining("confirmation")
);
용도: "이 의존성이 정확한 인자로 호출됐는가" 검증.
side effect (이메일 발송, 결제 등) 검증에 적합.3. Spy — 진짜 객체 + 호출 추적
const realLogger = new ConsoleLogger();
const spy = jest.spyOn(realLogger, "warn");
doWork(realLogger);
expect(spy).toHaveBeenCalledTimes(2);
// realLogger 는 실제 console 로 출력도 함 (default)
용도: 진짜 동작 유지하면서 호출 사실만 별도 검증.
또는 일부 method 만 mock (spy + mockReturnValue).4. Fake — 동작하는 단순 구현
class InMemoryUserRepo {
users = new Map();
save(u) { this.users.set(u.id, u); }
find(id) { return this.users.get(id); }
}
// 진짜 DB 와 같은 API, 메모리 안에서만 동작
test("user saved and findable", () => {
const repo = new InMemoryUserRepo();
repo.save({id: 1, name: "Alice"});
expect(repo.find(1).name).toBe("Alice");
});
용도: 무겁고 느린 dependency (DB, queue) 의 가벼운 대체.
testcontainer / sqlite in-memory 와 같은 영역.Over-Mocking — "100% coverage 인데 진짜 안전 X"
function createOrder(user, items) {
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
return {user, items, total};
}
test("creates order", () => {
const user = mock();
const items = [mock()];
// items[0] 의 price, qty 가 mock 이라 동작 안 됨
// 또는 mock(items[0].price 도 다시 mock)
const order = createOrder(user, items);
expect(order.user).toBe(user); // ← tautology
expect(order.items).toBe(items); // ← tautology
// total 검증은? 다 mock 이라 의미 X
});
문제:
- 100% 코드 line 실행
- 그러나 진짜 계산은 검증 X (input/output 모두 mock)
- coverage tool 은 그린, 진짜 버그 잡힘 XMocking 의 정답 경계
✓ Mock 가치 큰 영역
- 외부 network (HTTP API, third-party service)
- 시간 (Date.now, setTimeout)
- random (Math.random)
- filesystem (cross-platform 차이)
- 비싼 side effect (이메일, SMS, 결제)
✗ Mock 피해야 할 영역
- plain function / pure logic (직접 호출이 더 쉬움)
- data structure (mock 보다 fixture 객체)
- own code 의 내부 module (구현 결합 ↑)
- DB — fake (in-memory) 또는 testcontainer 가 더 안전
Time / Random Mock — 특수 영역
// 시간 mock
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-05-25T00:00:00Z"));
test("expires after 1 hour", () => {
const session = createSession();
jest.advanceTimersByTime(60 * 60 * 1000 + 1);
expect(session.expired()).toBe(true);
});
// random mock — 비결정적 test 회피
jest.spyOn(Math, "random").mockReturnValue(0.5);
→ test 결정성 확보. random 가 들어간 logic 도 reproducible 검증.Mock 의 구현 결합 함정
// 코드
async function getUser(id) {
const cached = await cache.get(id);
if (cached) return cached;
const user = await db.users.find(id);
await cache.set(id, user);
return user;
}
// test (over-specified)
test("fetches user", async () => {
cache.get.mockResolvedValue(null);
db.users.find.mockResolvedValue({id: 1, name: "A"});
cache.set.mockResolvedValue();
const user = await getUser(1);
expect(cache.get).toHaveBeenCalledWith(1);
expect(cache.get).toHaveBeenCalledTimes(1); // ← 구현 결합
expect(db.users.find).toHaveBeenCalledWith(1);
expect(cache.set).toHaveBeenCalledWith(1, {id: 1, name: "A"});
expect(user.name).toBe("A");
});
문제:
- caching 로직 refactor (예: cache.getOrSet 단일 호출) → test 깨짐
- 진짜 검증 = "user 반환" 뿐인데 5 assertion
- 구현 변경마다 test 수정 부담
좋은 test:
test("returns the user", async () => {
db.users.find.mockResolvedValue({id: 1, name: "A"});
const user = await getUser(1);
expect(user.name).toBe("A");
});Test Double 선택 가이드
| 상황 | 도구 |
|---|---|
| return value 만 필요 | Stub |
| 호출 자체가 검증 대상 (이메일·결제) | Mock |
| 진짜 동작 + 호출 추적 둘 다 | Spy |
| 가벼운 in-memory 대체 필요 | Fake |
| 외부 HTTP API | nock / msw (HTTP-level mock) |
| DB | testcontainer / sqlite in-memory (fake) |
| 시간 | jest.useFakeTimers / sinon |
흔한 함정
- plain function 도 mock — 직접 import + 호출이 더 쉽고 안전.
- 구현 detail 검증 — "cache.get 이 1번 호출됐다" 같은 over-specified. behavior 만 검증.
- mock 의 reset 누락 — test 간 state 누수 → flaky. beforeEach 의 jest.clearAllMocks().
- auto-mock 남용 — 모든 module 을 자동 mock 하면 어디서 진짜 통합 안 되는지 안 보임. 명시적 mock 권장.
- real network 대신 stub 만 — HTTP API 변경 시 production 만 깨짐. contract test (별도 가이드) 보강.
마무리
Mocking 의 본질 = "통제 어려운 의존을 통제 가능한 fake 로". 다만 과하면 구현 결합 + 진짜 통합 검증 누락. test double 의 4 종을 의도적으로 선택.
실용 — 외부 boundary (HTTP, time, random) 만 mock, 내부 logic 은 직접 호출. DB 는 fake (in-memory) 또는 testcontainer. behavior 검증 (output 기반), 구현 검증 (호출 횟수 등) 회피.