test
소프트웨어 개발 과정에서 테스트는 기능의 신뢰성을 확보하고, 변경에 안전한 구조를 유지하는 핵심 요소입니다. 그러나 테스트가 외부 시스템(DB, API, 메시지 브로커 등)에 직접 의존할 경우 다음과 같은 문제가 발생할 수 있습니다.
따라서 테스트에서는 가능한 한 외부 의존성을 제거하고, 내부 로직의 정확성에 집중하는 구조가 필요합니다. 이때 활용되는 기법이 Test Double입니다. Test Double은 실제 객체를 대신하여 테스트 흐름을 안정적으로 제어할 수 있는 대역 객체를 의미하며, 테스트 환경을 독립적이고 예측 가능하게 유지합니다. 이 방식은 테스트 속도를 높이고, 불필요한 환경 설정 비용을 줄이며, 코드 변경 시 영향 범위를 좁혀 유지보수성을 크게 개선합니다.
Test Double 중에서도 가장 자주 사용되는 것이 Mock과 Stub이며, 두 객체는 혼동되는 경우가 많지만 목적이 분명히 다릅니다.
| 구분 | Mock | Stub |
|---|---|---|
| 역할 | 메서드 호출 방식 및 행동을 검증 | 반환 값을 사전에 정의하여 상태를 통제 |
| 초점 | Behavior(행동) | State(상태) |
| 주요 목적 | 호출 횟수, 파라미터, 호출 여부 확인 | 시나리오에 필요한 특정 값을 반환하도록 설정 |
| 적합한 테스트 | 로직 흐름 검증 | 외부 의존성 제거 및 결과 기반 테스트 |
Mock은 어떻게 호출되었는가를 검증하고, Stub은 무엇을 반환할 것인가를 제어합니다. Mock은 행동 중심(Behavior Verification), Stub은 상태 중심(State Verification)이라는 차이를 기준으로 구분하면 명확합니다.
Mock은 메서드가 어떻게 호출되었는지를 확인하는 데 사용됩니다. 특정 로직이 의도한 대로 외부 의존 객체를 호출하고 있는지를 검증할 때 유용합니다.
// user.service.ts
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
registerUser(email: string, password: string) {
const user = { email, password };
this.userRepository.save(user);
}
}// userRepository interface
export interface UserRepository {
save(user: { email: string; password: string }): void;
}// user.service.spec.ts
import { UserService } from './user.service';
test('userRepository.save가 1회 호출되는지 검증', () => {
const mockRepository = {
save: jest.fn()
};
const userService = new UserService(mockRepository);
userService.registerUser('test@example.com', 'password123');
expect(mockRepository.save).toHaveBeenCalledTimes(1);
expect(mockRepository.save).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});Stub은 특정 반환 값을 미리 정의하여 테스트 상황을 통제하는 용도로 사용합니다. 이를 통해 외부 시스템이나 데이터 저장 과정이 실제로 일어나지 않더라도, 테스트 대상 로직이 해당 반환 값을 기반으로 제대로 동작하는지를 검증할 수 있습니다.
// user.service.ts (수정)
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
registerUser(email: string, password: string) {
const savedUser = this.userRepository.save({ email, password });
return savedUser.id;
}
}// user.service.spec.ts
import { UserService } from './user.service';
test('userRepository.save가 미리 정의된 값을 반환하도록 Stub 설정', () => {
const stubRepository = {
save: jest.fn().mockReturnValue({ id: 1, email: 'hanpy@example.com', password: 'pw123' })
};
const userService = new UserService(stubRepository);
const id = userService.registerUser('hanpy@example.com', 'pw123');
expect(id).toBe(1);
});Stub은 특정 반환 값을 미리 정의하여 테스트 상황을 통제하는 용도로 사용합니다. 이를 통해 외부 시스템이나 데이터 저장 과정이 실제로 일어나지 않더라도, 테스트 대상 로직이 해당 반환 값을 기반으로 제대로 동작하는지를 확인 할 수 있습니다.
test('결제 요청 메서드가 호출되었는지 검증', () => {
const mockGateway = {
charge: jest.fn().mockReturnValue(true)
};
const service = new PaymentService(mockGateway);
const request = { amount: 1000 };
service.processPayment(request);
expect(mockGateway.charge).toHaveBeenCalledTimes(1);
expect(mockGateway.charge).toHaveBeenCalledWith(request);
});mockGateway.charge를 expect에 넣은 이유는, PaymentService가 외부 의존성을 어떻게 사용하는지(행동) 를 검증하기 위함 입니다. PaymentService 내부에 코드를 고민해보면 아래와 같을 겁니다.
class PaymentService {
constructor(private gateway) {}
processPayment(request) {
// 핵심 로직: 외부 결제 게이트웨이를 호출함
return this.gateway.charge(request);
}
}여기서 gateway.charge()는 외부 시스템(결제 서버 등) 을 호출하는 부분입니다. 테스트에서는 실제 결제 API를 호출하면 안 되니까, 그 자리를 Mock 객체로 대체한 거죠.
test('결제 실패 상황을 Stub으로 시뮬레이션', () => {
const stubGateway = {
charge: jest.fn().mockReturnValue(false)
};
const service = new PaymentService(stubGateway);
const request = { amount: 1000 };
const result = service.processPayment(request);
expect(result).toBe(false);
});| 구분 | 테스트 대상 | 검증 포인트 | 사용하는 도구 |
|---|---|---|---|
| Mock 테스트 | PaymentService | 외부 객체(gateway) 호출 여부 / 횟수 / 인자 | jest.fn() + expect(...).toHaveBeenCalled... |
| Stub 테스트 | PaymentService | 외부 객체의 반환값에 따른 서비스 동작 결과 | jest.fn().mockReturnValue(...) + expect(result) |
| 항목 | Mock | Stub |
|---|---|---|
| 초점 | 메서드 호출 행위 검증 | 메서드 반환값 제어 |
| 목적 | 로직 흐름 검증 | 특정 상태 가정 테스트 |
| 장점 | 테스트 시나리오 명확화 | 예외 상황 테스트 용이 |
Test Double을 적절히 사용하면 테스트를 빠르게 수행할 수 있고 코드 구조도 느슨하게 유지할 수 있어 유지보수 효율이 크게 향상됩니다.