1. 초록불의 배신 - "완벽한 테스트였는데..."
주니어 시절, 저는 테스트 커버리지(Test Coverage) 100%를 달성하는 것에 집착했습니다.
그날도 UserService를 대대적으로 리팩토링하고 테스트를 돌렸습니다.
터미널에 뜬 아름다운 초록색(Green) 메시지들. "All tests passed."
"완벽해." 저는 자신만만하게 배포 버튼을 눌렀습니다.
하지만 5분 뒤, 슬랙 알림 채널이 폭발하기 시작했습니다.
TypeError: emailService.send is not a function
"어? 나 분명 이메일 보내는 부분 테스트했는데?"
식은땀을 흘리며 코드를 다시 봤습니다.
알고 보니 저는 리팩토링 과정에서 EmailService의 메서드 이름을 send에서 sendEmail로 바꿨습니다.
그런데 왜 테스트는 통과했을까요?
UserService의 테스트 코드에서 EmailService를 Mock킹(가짜 객체화)해서 강제로 성공시키고 있었기 때문입니다.
/* 내 테스트 코드의 실체 (The Lie) */
// 실제 코드는 sendEmail()로 바뀌었는데, 테스트용 가짜 객체는 여전히 send()를 가짐
const emailService = { send: jest.fn() };
// UserService는 이 가짜 객체(emailService)의 .send()를 호출함
userService.register(user, emailService);
// 가짜가 호출됐으니 "성공!"이라고 외침
expect(emailService.send).toHaveBeenCalled();
저는 내 실제 코드를 테스트한 게 아니라, 내가 상상으로 만든 가짜 객체를 테스트하고 있었던 겁니다. 이 사건은 저에게 큰 충격을 주었고, 저는 테스트 대역(Test Double)의 종류와 차이를 뼈저리게 다시 공부하게 되었습니다.
2. 테스트 대역의 족보 (Martin Fowler 스타일)
많은 개발자들이 "테스트용 가짜 객체"를 통틀어 그냥 "Mock"이라고 부릅니다. 하지만 마틴 파울러(Martin Fowler) 형님은 이것을 Test Double(테스트 대역)이라고 부르며, 역할에 따라 5가지로 나눴습니다. 이걸 구분 못 하면 저처럼 "거짓말쟁이 테스트"를 만들게 됩니다.
1. Dummy (허수아비)
- 역할: 인자 리스트를 채우기 위해 넘겨주는 객체. 절대 사용되지 않음.
- 예시: 함수 파라미터가 3개인데, 테스트하려는 건 첫 번째 파라미터뿐일 때 나머지 두 개에
null이나 빈 객체를 넣는 것.
const dummyUser = null; // 그냥 자리만 채움
userService.calculateTax(dummyUser, 10000); // 에러만 안 나게 함
2. Stub (대역 배우 - 상태 검증)
- 역할: "미리 준비된 답"을 무조건 반환하는 객체.
- 용도: "DB가 죽었을 때", "재고가 없을 때" 같은 상황을 시뮬레이션.
- 핵심: Stub은 상태(State) 검증에 쓰입니다.
/* Repository의 Stub */
const repoStub = {
// 누가 뭐라든 1번 유저를 리턴함
findById: () => ({ id: 1, name: "Ratia" })
};
const result = service.getUserName(repoStub);
expect(result).toBe("Ratia"); // 결과값(상태) 검증
3. Mock (행동 감시자 - 행위 검증)
- 역할: "얘가 호출됐나?"를 감시하는 객체.
- 용도: 리턴 값이 없는 함수(void)나, 부수 효과(Side Effect)를 검증할 때. (이메일 발송, 로그 기록 등)
- 핵심: Mock은 행위(Behavior) 검증에 쓰입니다. "호출 횟수", "호출 인자"를 따집니다.
const emailMock = jest.fn(); // 감시자 설정
service.register(user);
// "sendEmail이 1번 호출되었는가?" 검사
expect(emailMock).toHaveBeenCalledTimes(1);
(그 외 Spy, Fake가 있지만 이 둘이 가장 중요합니다.)
3. 그래서 뭐가 문제였나? (Stub vs Mock)
제 실패의 원인은 두 가지였습니다.
- Stub을 써야 할 곳에 Mock을 썼다.
- Mock 객체를 실제 구현과 동기화하지 않았다.
Stub은 쿼리(Query)에 써라
데이터를 조회하는함수(getUser, getProduct)는 Stub을 써야 합니다.
"getUser 함수가 호출되었니?"(Mocking)를 검사하는 건 의미가 없습니다. 그건 구현 세부사항(Implementation Detail)이니까요.
"getUser가 { name: 'A' }를 리턴했을 때, 서비스 로직이 'A님 안녕하세요'를 리턴하는가?"(Stubbing)를 검사해야 합니다.
Mock은 명령(Command)에 써라
이메일 발송, 결제 요청, 로그 기록 같은 작업은 리턴 값보다 "실행 여부"가 중요합니다.
이럴 때 Mock을 씁니다.
"결제 로직이 성공했으면, pgClient.pay()가 정확히 한 번 호출되어야 한다."
4. 더 나은 방법 - Fake (가짜 구현체)
최근 모던 테스팅의 트렌드는 "Mocking을 최대한 줄이자"입니다. Mock을 남발하면, 리팩토링할 때마다 테스트가 깨지고(Brittle Tests), 저처럼 "거짓말 테스트"에 속게 됩니다. 대신 Fake를 쓰는 것을 강력 추천합니다.
Fake는 실제 동작하는(Working) 가짜 구현체입니다.
/* Fake Database 구현 (In-Memory) */
class FakeUserRepository {
constructor() {
this.users = new Map();
}
save(user) {
this.users.set(user.id, user);
}
findById(id) {
return this.users.get(id);
}
}
이제 테스트에서 FakeUserRepository를 쓰면:
- 실제 동작을 하므로
jest.fn().mockReturnValue(...)같은 설정이 필요 없습니다. - 리팩토링 내성: 메서드 이름을 바꾸면 IDE의 리팩토링 기능이 Fake 클래스까지 같이 바꿔줍니다.
- 신뢰성: 실제 로직 흐름을 그대로 타기 때문에, 버그를 잡을 확률이 훨씬 높습니다.
구글(Google) 엔지니어링 팀도 "Mock보다는 Fake를 권장한다"고 가이드합니다.
5. 마무리 - "속이지 말고 흉내 내라"
테스트는 거짓말을 하지 않아야 합니다. 배포 전에 나에게 진실을 말해주는 유일한 친구여야 합니다.
하지만 우리가 jest.fn()으로 만든 Mock 객체는 종종 우리에게 달콤한 거짓말을 합니다. ("응, 호출 잘 됐어~ 실제 코드는 터졌지만 난 몰라~")
요약:
- Stub: "결과값"이 필요할 때 써라. (상태 검증)
- Mock: "호출 여부"가 중요할 때(Side Effect)만 써라. (행위 검증)
- Fake: 가능하다면 인메모리 구현체(Fake)를 만들어라. 가장 강력하고 안전하다.
테스트 코드를 짰는데 마음 한구석이 불안하다면, 혹시 당신은 실제 코드를 테스트하는 게 아니라 당신의 상상 속 친구(Mock)랑 대화하고 있는 건 아닌지 의심해 봐야 합니다.