
개요
테스트를 작성하다 보면 Mock, Stub, Spy 등 다양한 용어들을 마주치게 된다. 처음 테스트 코드를 작성할 때는 이러한 용어들의 차이점을 명확히 이해하기 어려웠다. 개발을 진행하면서 컴포넌트 테스트를 작성할 때 용어 자체에 대한 혼동이 지속되어 내용 정리를 결심하게 되었다.
이 글에서는 Test Double(테스트 더블)의 여러 종류들을 살펴보고, 각각 어떤 상황에서 유용한지 알아보려 한다. Test Double은 프론트엔드에만 국한된 개념이 아니라 소프트웨어 테스트 전반에서 사용되는 개념이다. 잘 이해하고 있으면 꼭 프론트엔드 코드가 아니더라도 효과적인 테스트 코드를 작성할 수 있고, 다른 개발자들의 코드도 더 쉽게 이해할 수 있을 것이다.
먼저 각 Test Double의 개념과 차이점을 살펴보고, 이후 실제 프론트엔드 개발에서 어떻게 활용되는지 알아보도록 하자.
Test Double의 이해
프론트엔드에서 테스트 코드를 작성할 때 실제 코드 대신 모방하는 객체를 사용해야 하는 경우가 있다. 예를 들어 API 호출이 포함된 컴포넌트를 테스트할 때, 실제 API를 호출하면 테스트가 느려지고, 네트워크 상태나 서버 상태에 따라 테스트 결과가 달라질 수 있다. 이런 경우 API 호출을 대신할 모방 객체가 필요한데, 이를 Test Double(테스트 더블)이라 한다.
우리는 구성요소 B의 동작을 모방하는 객체를 생성한다. B와 비슷하지만 B는 아니다. 우리는 테스트 내에서 이 가짜 구성요소 B를 완전히 제어할 수 있기 때문에 테스트 맥락에 따라 B처럼 행동하도록 할 수 있다. 따라서 실제 객체에 대한 의존성을 줄일 수 있다.
다른 객체의 동작을 시뮬레이션하는 객체를 사용하면 다음과 같은 장점이 있다.
- `더 큰 제어권을 가진다.` 우리는 이 Fake 객체에게 무엇을 해야 할지 쉽게 알려줄 수 있다. 예외를 던지는 방법을 원한다면, 그것을 Mock하는 방법을 알려준다. 예외를 던지는 종속성을 강제하기 위해 복잡한 설정이 필요하지 않다. 클래스가 예외를 던지거나 가짜 날짜를 생성하도록 강제하는 것이 얼마나 어려운지를 생각해보자. Mock 객체로 종속성을 시뮬레이션하면 이 노력은 0에 가깝다.
- `시뮬레이션은 빠르다.` 웹 서비스나 데이터베이스와 통신이 필요한 경우를 상상해보자. 종속성을 가진 클래스는 어떤 메서드를 실행하는 데 몇 초 정도 걸릴 수 있다. 반면에 종속성을 시뮬레이션하면 데이터베이스나 웹 서비스와 통신하고 응답을 기다릴 필요가 없다. 시뮬레이션은 미리 구성한 값을 반환할 것이며 시간 측면에서 비용이 들지 않을 것이다.
- `클래스 간 상호작용` 개발자는 Mock 객체를 설계 기법으로 사용해 각 상호작용을 반영할 수 있다. 또 계약이 어떻게 되어야 하는지, 개념적인 경계는 어떻게 나눌지 반영할 수 있다. 따라서 개발자는 모의 객체를 사용해 테스트를 쉽게 만들고 모의 객체를 코드 설계의 지원 도구로 사용할 수 있다.
우리의 목표는 시스템의 다른 단위에 크게 신경쓰지 않고 단일 단위에 집중하는 것이기 때문에, 필요자는 테스트 흐름에서 Mock 객체를 단위 테스트 영역에 넣었다. 그러나 시뮬레이션은 시뮬레이션된 클래스가 약속한 바와 동일한 작업을 수행해야 하므로 우리는 여전히 종속성 계약을 신경써야 한다.
예를 들어 주문 처리 시스템을 테스트할 때 실제 결제 시스템 대신 Mock 결제 시스템을 사용할 수 있다. 이때 Mock 결제 시스템은 실제 결제가 이루어지지는 않지만, 결제 성공/실패 여부를 실제 시스템과 동일한 방식으로 반환해야 한다. 즉, Mock 객체는 실제 객체의 인터페이스를 그대로 따르면서, 내부 구현만 단순화하는 것이다.
이렇게 함으로써 테스트를 단순화하고 격리시키는 동시에 시스템 컴포넌트 간의 올바른 상호작용을 보장한다. 따라서 Mock을 사용할 때는 단순히 대체품을 만드는 것이 아니라 실제 객체의 동작 방식을 이해하고 Mock에 반영해야 한다.
Dummy 객체
테스트 더블은 목적에 따라 여러 종류가 있다. Dummy(더미)는 가장 단순한 형태로, 단순히 인터페이스를 맞추기 위해 사용된다. 실제로는 아무 동작도 하지 않으며 함수가 호출되면 null이나 빈 문자열 같은 기본값만 반환한다.
예를 들어 UserProfile 컴포넌트를 테스트한다고 생각해보자. 이 컴포넌트는 user, theme, onLogout 등 여러 props를 받을 수 있다. 프로필 이미지 표시 기능을 테스트할 때는 theme이나 onLogout은 테스트와 관련이 없다. 이런 경우 더미 함수나 객체를 전달할 수 있다.
const dummyTheme = {} // 빈 객체
const dummyOnLogout = () => {} // 아무것도 하지 않는 함수
render(
<UserProfile
user={actualUserData}
theme={dummyTheme}
onLogout={dummyOnLogout}
/>
)
이처럼 더미 객체는 테스트에 직접적으로 필요하지 않은 의존성을 채우는 용도로 사용된다.
Fake 객체
Fake(페이크)는 더미보다 진화한 형태로, 실제 구현을 단순화한 객체다. 실제로 동작하는 구현체이지만 프로덕션 환경에서 사용하는 것보다 가벼운 버전이다. 예를 들어 복잡한 전역 상태 관리 라이브러리 대신 단순한 상태값만 관리하는 Context Provider를 사용하거나, API 호출 대신 로컬 배열에서 데이터를 반환하는 서비스 객체를 사용할 수 있다.
// 실제 구현
const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const response = await api.post('/auth/login', credentials);
setUser(response.data.user);
localStorage.setItem('token', response.data.token);
// ... 기타 복잡한 인증 로직
};
return <AuthContext.Provider value={{ user, login }}>{children}</AuthContext.Provider>;
};
// 테스트용 페이크 구현
const FakeAuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const login = (credentials) => {
setUser({ id: 1, name: 'Test User' });
// 실제 API 호출이나 토큰 관리 없이 즉시 로그인 처리
};
return <AuthContext.Provider value={{ user, login }}>{children}</AuthContext.Provider>;
};
페이크 객체는 실제 구현체의 동작을 모방하지만, 테스트에 필요한 핵심 기능만 단순화한 후 구현해 테스트 환경에서 더 빠르고 예측 가능한 동작을 보장할 수 있다.
Stub 객체
Stub(스텁)은 미리 준비된 데이터를 반환하는 객체다. 호출되면 항상 같은 값을 반환하고 주로 특정 상태를 테스트할 때 사용한다. Fake 객체와는 달리 Stub은 실제 동작하는 구현체가 없고 미리 정의된 응답만 반환한다.
const productData = [
{ id: 1, name: "상품 1", price: 1000 },
{ id: 2, name: "상품 2", price: 2000 }
];
// API 호출을 스텁으로 대체
vi.mock('@/api/products', () => ({
fetchProducts: () => Promise.resolve(productData)
}));
it('상품 목록을 렌더링해야 한다', async () => {
render(<ProductList />);
// API 응답을 기다림
await waitFor(() => {
expect(screen.getByText('상품 1')).toBeInTheDocument();
expect(screen.getByText('상품 2')).toBeInTheDocument();
});
});
Stub은 가장 자주 사용되는 Test Double 중 하나다. API 호출이나 외부 서비스 호출을 포함하는 컴포넌트를 테스트할 때 자주 사용된다. API를 호출하는 대신 Stub을 사용하면 테스트가 빠르고 안정적으로 실행되고 다양한 시나리오(성공, 실패, 로딩 상태 등)를 테스트할 수 있다.
Spy 객체
Spy(스파이) 객체는 자신이 어떻게 호출되었는지를 기록하는 관찰자다. 주로 이벤트 핸들러나 콜백 함수가 의도한 대로 호출되는지 검증할 때 사용한다. 예를 들어 버튼 클릭 핸들러가 정확히 한 번 호출되었는지, Form submit 이벤트가 올바른 데이터와 함께 호출되었는지 등을 확인할 수 있다.
it('검색어 입력 시 API를 호출해야 한다', () => {
const searchSpy = vi.fn();
render(
<SearchBar onSearch={searchSpy} />
);
const input = screen.getByRole('searchbox');
fireEvent.change(input, { target: { value: '검색어' } });
expect(searchSpy).toHaveBeenCalledTimes(1);
expect(searchSpy).toHaveBeenCalledWith('검색어');
});
위 예시에서 vi.fn()이 바로 spy 역할을 한다. 이 함수는 일반 함수처럼 동작하면서도 자신이 어떻게 호출되었는지에 대한 정보를 기록한다.
// spy
const logSpy = vi.fn((message) => {
console.log(message);
});
logSpy('테스트');
// spy가 기록한 정보 확인
console.log(logSpy.mock.calls.length); // 호출 횟수: 1
console.log(logSpy.mock.calls[0][0]); // 첫 번째 호출의 첫 번째 인자: '테스트'
console.log(logSpy.mock.results[0].value); // 첫 번째 호출의 반환값
Spy 객체는 함수나 메서드의 호출을 감시하면서도 실제 구현은 그대로 동작하게 할 수 있다. 콜백이 제대로 호출되는지 검증하면서도 실제 동작은 방해하지 않아야 할 때 쓰면 좋다. 다만 테스트가 구현 세부사항에 너무 의존하게 될 수 있어 컴포넌트의 동작을 검증할 때는 주의해서 사용해야 한다.
Mock 객체
마지막으로 Mock(모의) 객체는 가장 복잡하지만 강력한 Test Double이다. Stub과 Spy의 특징을 모두 가지고 있으며, 호출될 것으로 기대되는 동작을 미리 프로그래밍할 수 있다. 특정 메서드가 특정 인자와 함께 반드시 호출되어야 한다거나, 특정 순서로 메서드가 호출되어야 한다는 식의 기대 사항을 정의하고 검증할 수 있다.
Mock 객체는 메서드의 응답을 설정할 수 있다는 점에서 Stub 같은 역할을 한다. 하지만 Mock 객체는 그 이상이다. Mock 객체는 모든 상호작용을 저장해서 나중에 단언문에 활용할 수 있도록 해준다. 예를 들어 특정 메서드가 한 번만 호출되길 바랄 수 있다. 만약 테스트 대상이 이를 두 번 호출하면 이는 곧 버그가 있다는 뜻이고 테스트는 실패한다.
프론트엔드 테스트에서는 주로 API 호출이나 외부 서비스와의 상호작용을 모의할 때 Mock 객체를 사용한다.
describe('LoginForm', () => {
it('로그인 후 사용자 정보를 가져와야 한다', async () => {
const mockAuth = {
login: vi.fn().mockResolvedValue({ token: 'fake-token' }),
getUser: vi.fn().mockResolvedValue({ id: 1, name: 'User' })
};
render(<LoginForm auth={mockAuth} />);
await userEvent.click(screen.getByRole('button', { name: '로그인' }));
// Mock 객체를 통한 다양한 검증 가능
expect(mockAuth.login).toHaveBeenCalledBefore(mockAuth.getUser); // 호출 순서 검증
expect(mockAuth.getUser).toHaveBeenCalledTimes(1); // 호출 횟수 검증
});
});
Mocking 프레임워크를 사용하면 메서드 호출의 모든 측면(호출 횟수, 전달된 인자, 호출 순서 등)을 제어하고 검증할 수 있다. 이러한 특성 덕분에 Mock은 복잡한 상호작용을 테스트할 때 좋다.
정리
- Dummy는 가장 기본적인 형태의 테스트 대역으로 실제로는 사용되지 않지만 인터페이스를 맞추기 위해 전달되는 객체다. 테스트 자체와는 관계없는 필수 매개변수를 채우는 용도로 사용한다.
- Fake는 실제 구현을 단순화한 대체 구현체다. 실제의 객체와 비슷하게 동작하지만 테스트 환경에 맞게 단순화되어 있다. 예를 들어 실제 데이터베이스 대신 메모리에 데이터를 저장하는 가짜 데이터베이스가 대표적이다.
- Stub은 미리 준비된 결과를 반환하는 객체다. 호출되면 항상 정해진 값을 반환하며, 복잡한 상태나 로직 없이 단순히 원하는 결과값만 제공한다. API 호출을 대체할 때 자주 사용된다.
- Spy는 자신이 어떻게 호출되었는지를 기록하는 객체다. 메서드의 호출 횟수, 전달된 인자 등을 추적할 수 있어 객체 간의 상호작용을 검증할 때 유용하다. 이벤트 핸들러나 콜백 함수가 제대로 호출되는지 확인하는데 주로 사용된다.
- Mock은 가장 복잡하지만 강력한 테스트 대역이다. Stub과 Spy의 기능을 모두 가지고 있으며, 어떤 메서드가 어떻게 호출되어야 하는지에 대한 기대사항을 미리 프로그래밍할 수 있다. 호출 순서, 횟수, 인자 등을 세세하게 검증할 수 있어 복잡한 상호작용을 테스트하는데 적합하다.
'Web, Front-end > 테스트' 카테고리의 다른 글
시나리오 구성 및 테스트 코드 작성 (0) | 2025.01.16 |
---|---|
프론트엔드 단위 테스트 이해하기 (0) | 2025.01.08 |
컴퓨터 전공 관련, 프론트엔드 개발 지식들을 공유합니다. React, Javascript를 다룰 줄 알며 요즘에는 Typescript에도 관심이 생겨 공부하고 있습니다. 서로 소통하면서 프로젝트 하는 것을 즐기며 많은 대외활동으로 개발 능력과 소프트 스킬을 다듬어나가고 있습니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!