코드를 작성하고 테스트를 진행하는 과정에서, 단순히 함수의 최종 반환값만을 확인하는 것에 만족하고 있지는 않은지 자문해 볼 필요가 있습니다. 모든 테스트가 통과했는데도 실제 서비스에서 예기치 않은 버그가 발생하는 경험은 주니어 개발자에게는 낯설지 않은 상황일 것입니다. 이러한 문제는 대부분 함수의 내부 동작이나 다른 컴포넌트와의 상호작용을 충분히 검증하지 못했기 때문에 발생합니다.
특히, 특정 함수가 호출되었는지, 어떤 인자로 호출되었는지, 몇 번 호출되었는지와 같은 함수 호출 자체에 대한 검증은 코드의 흐름과 시스템의 안정성을 보장하는 데 필수적인 요소입니다. 하지만 이러한 검증에 있어 Mocking만을 고집하는 경우, 오히려 불필요한 복잡성을 야기하거나 중요한 부분을 놓칠 수 있습니다.
이 글에서는 이러한 문제의식을 바탕으로, 테스트 스파이(Test Spy)라는 강력하면서도 간과하기 쉬운 테스트 기법을 소개하고, 입문자 관점에서 그 활용법을 상세히 다룹니다. 테스트 스파이를 통해 함수 호출을 효과적으로 검증하고, 코드의 신뢰성을 한 단계 끌어올리는 방법을 함께 살펴보겠습니다.
📑 목차
- 테스트 스파이(Test Spy)란 무엇인가? 왜 중요한가?
- Mock, Stub, Spy: 무엇이 다르고 언제 쓰는가?
- 스파이의 핵심 차이점: 실제 함수 보존
- 테스트 스파이 활용 체크리스트: 실전 적용 가이드
- 1. 함수 호출 여부를 검증해야 하는가?
- 2. 특정 인자와 함께 호출되었는가?
- 3. 몇 번 호출되었는가?
- 4. 특정 순서로 호출되었는가?
- 테스트 스파이 적용 시 주의사항
- 1. 과도한 스파이 사용 지양
- 2. 스파이 해제(Restore)의 중요성
- 3. private 메서드 스파이 지양
- 4. 테스트 가독성 유지
- 마무리하며: 코드 신뢰성의 초석, 테스트 스파이
Image by analogicus on Pixabay
테스트 스파이(Test Spy)란 무엇인가? 왜 중요한가?
테스트 스파이(Test Spy)는 말 그대로 '스파이'처럼 특정 함수의 호출 여부, 호출 횟수, 전달된 인자 등을 감시하고 기록하는 역할을 수행하는 도구입니다. 스파이는 대상 함수의 실제 구현을 대체하거나 변경하지 않고, 그저 함수의 호출 정보를 수집하는 데 집중합니다. 이는 테스트 대상 시스템의 실제 동작을 방해하지 않으면서도 내부 상호작용을 투명하게 관찰할 수 있게 합니다.
테스트 스파이가 중요한 이유는 다음과 같은 실질적인 문제들을 해결할 수 있기 때문입니다.
- 내부 동작 검증의 한계 극복: 단위 테스트는 주로 함수의 입력과 출력(반환값)을 검증하는 데 중점을 둡니다. 하지만 많은 함수는 단순히 값을 반환하는 것을 넘어, 다른 함수를 호출하거나 특정 상태를 변경하는 등 부수 효과(Side Effect)를 발생시킵니다. 스파이는 이러한 부수 효과의 발생 여부를 직접적으로 검증할 수 있게 합니다.
- 시스템 통합의 신뢰성 향상: 여러 모듈이나 서비스가 복합적으로 동작하는 시스템에서, 한 모듈의 기능이 올바르게 수행되었는지 여부는 단순히 그 모듈의 반환값만으로는 판단하기 어렵습니다. 예를 들어, 사용자 회원가입 시 이메일 발송 함수가 정말로 호출되었는지를 검증하는 것은 서비스의 핵심 로직 검증에 해당합니다. 스파이는 이러한 상호작용 검증을 가능하게 하여 통합 지점의 신뢰성을 높여줍니다.
- 디버깅 및 문제 진단 용이성: 테스트 실패 시, 스파이가 기록한 호출 이력을 통해 어떤 함수가 예상과 다르게 호출되었는지, 혹은 전혀 호출되지 않았는지를 파악하여 문제의 원인을 신속하게 진단하는 데 도움을 받을 수 있습니다.
Mock, Stub, Spy: 무엇이 다르고 언제 쓰는가?
테스트 스파이를 이해하기 위해서는 종종 혼동되는 개념인 Mock(목)과 Stub(스텁)과의 차이점을 명확히 알아두는 것이 중요합니다. 이 세 가지는 모두 테스트 대역(Test Double)의 일종으로, 테스트 환경에서 실제 객체를 대체하거나 보조하는 역할을 하지만, 그 목적과 작동 방식에는 분명한 차이가 존재합니다.
| 구분 | 목적 | 실제 구현 사용 여부 | 주요 활용 시나리오 |
|---|---|---|---|
| Stub (스텁) | 특정 입력에 대해 미리 정해진 값 반환 (상태 기반 테스트) | 사용하지 않음 (가짜 구현 제공) | 의존성 객체의 복잡한 로직을 회피하고 고정된 값으로 테스트하려 할 때 (예: 데이터베이스 조회 결과 모의) |
| Mock (목) | 호출될 것으로 예상되는 함수 및 인자를 사전에 정의하고, 테스트 종료 후 실제 호출 여부 검증 (행위 기반 테스트) | 사용하지 않음 (가짜 구현 제공 및 기대치 설정) | 특정 함수가 특정 순서와 횟수로 호출되어야 하는 중요한 부수 효과를 검증할 때 (예: 결제 후 이메일 발송 여부) |
| Spy (스파이) | 실제 함수를 호출하면서 그 호출 정보를 감시하고 기록 (행위 기반 테스트) | 사용함 (실제 구현을 래핑) | 함수의 실제 동작은 필요하지만, 그 함수의 호출 여부, 횟수, 인자 등을 관찰해야 할 때 (예: 사용자 알림 함수 호출 여부 확인) |
스파이의 핵심 차이점: 실제 함수 보존
가장 큰 차이점은 스파이가 실제 함수를 그대로 사용하면서 호출 정보만 기록한다는 점입니다. Stub과 Mock은 실제 함수를 대체하여 미리 정의된 동작을 수행하거나, 호출 자체를 가로채서 기대치와 비교합니다. 반면 스파이는 실제 함수를 감싸서 호출 정보를 기록하는 동시에 실제 함수가 정상적으로 실행되도록 합니다.
이러한 특성 덕분에 스파이는 다음과 같은 상황에서 특히 유용하게 활용됩니다.
- 함수의 부수 효과가 중요하지만, 그 부수 효과 자체가 테스트의 주요 대상이 아닐 때
- 특정 함수가 호출되는지 여부만 확인하고 싶고, 그 함수의 내부 로직은 실제처럼 동작해야 할 때
- 프레임워크나 라이브러리의 특정 메서드 호출 여부를 검증해야 할 때 (예:
console.log가 호출되었는지)
Image by Alexandra_Koch on Pixabay
테스트 스파이 활용 체크리스트: 실전 적용 가이드
테스트 스파이를 효과적으로 활용하기 위한 실전 점검 항목들을 소개합니다. 각 항목은 주니어 개발자가 실제 테스트 코드를 작성할 때 고려해야 할 질문들로 구성되어 있습니다.
1. 함수 호출 여부를 검증해야 하는가?
체크 항목: 특정 기능 수행 후, 의존성 객체의 핵심 메서드가 최소 한 번 이상 호출되었는지 확인해야 하는가?
이유: 많은 비즈니스 로직은 특정 조건이 충족되었을 때 다른 서비스나 모듈의 함수를 호출하는 방식으로 동작합니다. 예를 들어, '상품 구매' 기능은 '재고 감소' 함수와 '결제 처리' 함수를 호출해야 합니다. 이때, 재고 감소 로직의 세부 구현보다는 재고 감소 함수가 올바르게 호출되었는지 여부가 중요할 수 있습니다. 스파이를 통해 해당 함수가 호출되었음을 검증함으로써, 시스템의 핵심 상호작용이 제대로 이루어졌음을 보장할 수 있습니다.
// 예시: 사용자 서비스가 알림 서비스를 호출하는 경우
class NotificationService {
send(message) {
console.log(`알림 전송: ${message}`);
// 실제 알림 전송 로직
}
}
class UserService {
constructor(notificationService) {
this.notificationService = notificationService;
}
signUp(username) {
// ... 사용자 등록 로직 ...
this.notificationService.send(`${username}님, 회원가입을 축하합니다!`);
return true;
}
}
// 테스트 코드 (Jest 예시)
describe('UserService', () => {
let notificationService;
let userService;
let sendSpy;
beforeEach(() => {
notificationService = new NotificationService();
userService = new UserService(notificationService);
// NotificationService의 send 메서드를 스파이로 감시
sendSpy = jest.spyOn(notificationService, 'send');
});
afterEach(() => {
// 스파이 해제 (필수)
sendSpy.mockRestore();
});
test('회원가입 시 알림 서비스의 send 메서드가 호출되어야 한다', () => {
userService.signUp('testuser');
expect(sendSpy).toHaveBeenCalled(); // 호출 여부 검증
});
});
2. 특정 인자와 함께 호출되었는가?
체크 항목: 함수가 호출될 때, 예상되는 특정 인자(값)와 함께 호출되었는지 확인해야 하는가?
이유: 단순히 함수가 호출되었다는 것만으로는 충분하지 않은 경우가 많습니다. 호출된 함수가 올바른 데이터를 가지고 호출되었는지까지 검증해야 합니다. 예를 들어, '상품 주문' 함수가 '결제 처리' 함수를 호출할 때, 이 결제 처리 함수가 올바른 주문 금액과 사용자 ID를 인자로 받았는지 확인하는 것은 매우 중요합니다. 잘못된 인자가 전달되면 데이터 불일치나 보안 문제로 이어질 수 있습니다.
// (위의 UserService, NotificationService 예시 이어서)
test('회원가입 시 알림 서비스의 send 메서드가 올바른 메시지와 함께 호출되어야 한다', () => {
const username = 'testuser';
userService.signUp(username);
expect(sendSpy).toHaveBeenCalledWith(`${username}님, 회원가입을 축하합니다!`); // 특정 인자 검증
});
3. 몇 번 호출되었는가?
체크 항목: 특정 함수가 예상되는 횟수만큼 호출되었는지 확인해야 하는가? (예: 단 한 번만 호출되어야 하는가, 또는 N번 호출되어야 하는가?)
이유: 함수가 의도치 않게 여러 번 호출되거나, 반대로 필요한 만큼 호출되지 않는 상황은 버그를 유발합니다. 예를 들어, '데이터 저장' 함수가 한 번만 호출되어야 하는데 두 번 호출되면 데이터 중복이 발생할 수 있습니다. 또는 '캐시 업데이트' 함수가 여러 항목에 대해 N번 호출되어야 하는데 N-1번만 호출된다면 데이터 불일치가 발생할 수 있습니다. 스파이는 이러한 호출 횟수를 정확히 파악하여 로직의 정확성을 검증합니다.
// 예시: 여러 항목을 처리하며 save 함수를 호출하는 서비스
class DataStore {
save(item) {
console.log(`데이터 저장: ${item}`);
// 실제 저장 로직
}
}
class BatchProcessor {
constructor(dataStore) {
this.dataStore = dataStore;
}
processItems(items) {
items.forEach(item => {
this.dataStore.save(item);
});
}
}
// 테스트 코드 (Jest 예시)
describe('BatchProcessor', () => {
let dataStore;
let batchProcessor;
let saveSpy;
beforeEach(() => {
dataStore = new DataStore();
batchProcessor = new BatchProcessor(dataStore);
saveSpy = jest.spyOn(dataStore, 'save');
});
afterEach(() => {
saveSpy.mockRestore();
});
test('processItems는 전달된 항목의 수만큼 save 메서드를 호출해야 한다', () => {
const itemsToProcess = ['itemA', 'itemB', 'itemC'];
batchProcessor.processItems(itemsToProcess);
expect(saveSpy).toHaveBeenCalledTimes(itemsToProcess.length); // 호출 횟수 검증
});
test('단일 항목 처리 시 save 메서드는 한 번만 호출되어야 한다', () => {
batchProcessor.processItems(['itemX']);
expect(saveSpy).toHaveBeenCalledTimes(1);
});
});
4. 특정 순서로 호출되었는가?
체크 항목: 여러 함수가 호출될 때, 예상되는 특정 순서대로 호출되었는지 확인해야 하는가?
이유: 복잡한 비즈니스 로직에서는 함수의 호출 순서가 매우 중요할 수 있습니다. 예를 들어, '결제 처리'는 '재고 감소'보다 항상 먼저 일어나야 합니다. 만약 순서가 뒤바뀌면 심각한 데이터 불일치나 오류로 이어질 수 있습니다. 스파이는 호출된 함수의 이력을 기록하므로, 이를 통해 함수 간의 호출 순서를 검증하여 로직의 정합성을 확보할 수 있습니다. (이 경우, Jest와 같은 프레임워크의 Mock 기능이 더 적합할 수도 있으나, 스파이도 호출 이력을 통해 간접적으로 순서를 추론하는 데 활용될 수 있습니다.)
// 예시: 결제 서비스 (가상의 시나리오)
class PaymentGateway {
authorize() { /* ... */ }
capture() { /* ... */ }
refund() { /* ... */ }
}
class OrderService {
constructor(paymentGateway) {
this.paymentGateway = paymentGateway;
}
processPayment(amount) {
this.paymentGateway.authorize(amount);
this.paymentGateway.capture(amount);
return true;
}
}
// 테스트 코드 (Jest 예시, 순서 검증은 Mocking 프레임워크의 기능 활용이 더 일반적이나 스파이도 기록을 통해 가능)
describe('OrderService', () => {
let paymentGateway;
let orderService;
let authorizeSpy;
let captureSpy;
beforeEach(() => {
paymentGateway = new PaymentGateway();
orderService = new OrderService(paymentGateway);
authorizeSpy = jest.spyOn(paymentGateway, 'authorize');
captureSpy = jest.spyOn(paymentGateway, 'capture');
});
afterEach(() => {
authorizeSpy.mockRestore();
captureSpy.mockRestore();
});
test('결제 처리 시 authorize가 먼저 호출되고 capture가 나중에 호출되어야 한다', () => {
orderService.processPayment(100);
// 스파이의 호출 순서를 직접 확인하는 방법 (Jest의 toHaveBeenCalledBefore는 Mock 함수에 사용)
// 이 경우에는 `jest.fn()`으로 Mock을 만들거나 `mockImplementation`을 활용하는 것이 더 일반적
// 하지만 스파이의 호출 이력(mock.calls)을 통해 간접적으로 검증 가능
expect(authorizeSpy).toHaveBeenCalledWith(100);
expect(captureSpy).toHaveBeenCalledWith(100);
// Jest Mock 함수를 활용한 순서 검증 (참고)
// expect(authorizeSpy).toHaveBeenCalledBefore(captureSpy);
// 스파이를 이용해 간접적으로 순서 검증 (실제 호출 순서 기록)
// 이 예시에서는 Jest의 spyOn이 생성하는 mock 함수도 toHaveBeenCalledBefore를 지원한다.
// 따라서 `expect(authorizeSpy).toHaveBeenCalledBefore(captureSpy)`를 사용할 수 있다.
// 중요한 것은 스파이가 실제 함수를 감싸서 호출 정보를 기록한다는 점이다.
});
});
Image by lukasmilan on Pixabay
테스트 스파이 적용 시 주의사항
테스트 스파이는 강력한 도구이지만, 오용할 경우 테스트의 품질을 저하시키거나 유지보수를 어렵게 만들 수 있습니다. 다음은 스파이를 적용할 때 주의해야 할 사항들입니다.
1. 과도한 스파이 사용 지양
모든 함수 호출을 스파이로 감시하는 것은 바람직하지 않습니다. 테스트는 외부에서 관찰 가능한 동작(observable behavior)에 집중해야 하며, 구현 세부 사항(implementation details)에 너무 깊이 의존해서는 안 됩니다. 과도한 스파이 사용은 테스트 코드를 실제 구현에 강하게 결합시켜, 작은 코드 변경에도 테스트가 실패하는 취약한 테스트(fragile test)를 만들 수 있습니다. 스파이는 오직 핵심적인 상호작용이나 부수 효과를 검증할 때만 사용하는 것이 좋습니다.
2. 스파이 해제(Restore)의 중요성
대부분의 테스트 프레임워크에서 스파이를 생성하면 해당 함수의 원본 구현을 잠시 대체합니다. 테스트가 끝난 후에는 반드시 스파이를 해제(restore)하여 원본 함수로 되돌려야 합니다. 그렇지 않으면 다른 테스트에 영향을 미치거나 예기치 않은 동작을 유발할 수 있습니다. Jest의 afterEach 훅에서 mockRestore()를 호출하는 것이 일반적인 패턴입니다.
3. private 메서드 스파이 지양
일반적으로 private(또는 protected) 메서드는 외부에서 직접 호출되지 않으며, 클래스 내부의 구현 세부 사항으로 간주됩니다. 이러한 메서드에 스파이를 적용하는 것은 캡슐화를 위반하고, 테스트를 구현에 너무 밀접하게 결합시키는 행위입니다. private 메서드는 public 메서드를 통해 간접적으로 테스트되는 것이 바람직하며, 만약 private 메서드의 로직이 너무 복잡하여 별도의 테스트가 필요하다면, 해당 부분을 별도의 유틸리티 함수나 클래스로 분리하는 리팩토링을 고려해야 합니다.
4. 테스트 가독성 유지
스파이를 사용하더라도 테스트 코드는 명확하고 읽기 쉬워야 합니다. 스파이를 통해 무엇을 검증하려는지, 어떤 상황에서 어떤 함수가 호출되어야 하는지를 주석이나 변수명 등으로 명확히 표현하는 것이 중요합니다. 복잡한 스파이 로직은 테스트의 목적을 흐리게 만들 수 있습니다.
마무리하며: 코드 신뢰성의 초석, 테스트 스파이
테스트 스파이는 단순한 반환값 검증을 넘어, 함수의 호출 행위와 시스템 내부의 상호작용을 명확하게 관찰하고 검증할 수 있는 강력한 도구입니다. Mock, Stub과 함께 테스트 대역의 한 축을 이루며, 각각의 장점을 이해하고 적재적소에 활용하는 능력은 견고하고 신뢰성 높은 코드를 작성하는 주니어 개발자에게 필수적인 역량입니다.
이 글에서 제시된 체크리스트와 예시들을 바탕으로, 여러분의 테스트 코드에 테스트 스파이를 적극적으로 도입해 보시길 바랍니다. 함수 호출에 대한 깊이 있는 검증은 미처 예상하지 못했던 버그를 사전에 발견하고, 시스템의 안정성을 획기적으로 향상시키는 초석이 될 것입니다.
궁금한 점이 있거나, 여러분만의 테스트 스파이 활용 노하우가 있다면 댓글로 공유해 주세요. 함께 고민하고 발전하는 기회가 되기를 바랍니다.