7편. Vitest Mocking 이해하기 : vi.mock()과 vi.fn()
📚 목차
1. Mocking, 왜 필요할까?
2. vi.fn(): 스파이와 가짜 함수 (The Spy)
3. vi.mock(): 모듈 통째로 갈아끼우기 (The Replacement)
4. Mock 초기화와 관리 (Clean up)

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /vitest
1. Mocking, 왜 필요할까?
모킹은 실제 객체나 모듈을 '가짜(Mock)'로 대체하여, 테스트하려는 코드만 고립시켜 검증하는 기술입니다.
① 외부 의존성으로부터의 고립 (Isolation)
우리가 작성한 함수가 내부적으로 데이터베이스(DB)에 접속하거나, 외부 결제 API를 호출한다고 가정해 봅시다. 만약 DB 서버가 다운되거나 API 점검 중이라면, 정작 우리 코드에는 문제가 없어도 테스트는 실패하게 됩니다.
② 예측 불가능한 상황의 제어 (Determinism)
테스트는 언제 실행해도 항상 같은 결과가 나와야 합니다(결정론적 테스트). 하지만 현실 세계에는 매번 결과가 바뀌는 요소들이 많습니다.
▸ 현재 시간: new Date()를 사용하는 로직은 실행하는 시점에 따라 결과가 달라집니다.
▸ 랜덤 값: 로또 번호 생성기나 암호화 토큰 생성 로직은 매번 값이 바뀝니다.
▸ 해결책: 모킹을 통해 "시간은 항상 2026년 1월 1일이다", "랜덤 값은 항상 0.5다"라고 고정함으로써 테스트의 신뢰성을 확보합니다.
③ 테스트 실행 속도와 비용 (Efficiency)
실제 API를 호출하는 테스트가 1,000개 있다고 상상해 보세요. 테스트를 돌릴 때마다 수 분이 걸릴 것이고, 만약 유료 API라면 매 테스트마다 비용이 발생합니다.
▸ 비효율: 실제 네트워크 통신 대기 시간(Latency), 유료 인프라 비용 발생.
▸ 효율: 메모리 상에서 즉시 반환되는 가짜 함수를 사용해 수천 개의 테스트를 수 초 내에 완료합니다.
2. vi.fn(): 스파이와 가짜 함수 (The Spy)
vi.mock()이 모듈 전체를 교체하는 대형 공사라면, vi.fn()은 특정 함수 하나를 정교하게 가로채는 '스파이'를 심는 것과 같습니다.
1. 개념: vi.fn()이란?
vi.fn()은 아무런 로직이 없는 함수(Mock Function)를 생성합니다. 이 함수는 단순한 가짜를 넘어, 자신에게 일어난 모든 일을 기록합니다.
▸ "이 함수가 몇 번 호출되었는가?"
▸ "호출될 때 어떤 인자가 전달되었는가?"
▸ "결과값으로 무엇을 반환했는가?"
이러한 특성 때문에 테스트 코드에서는 이를 Spy(스파이)라고도 부릅니다.
2. 기본 구조와 사용법
가장 많이 사용되는 3가지 패턴 예시 입니다.
// tests/ch07/7-1-1.기본구조.test.ts
import { vi, describe, it, expect } from 'vitest';
describe('Vitest Mock 함수 활용 테스트', () => {
it('1. vi.fn()은 함수 호출 여부와 인자를 감시할 수 있다', () => {
const spy = vi.fn();
spy('hello');
// 함수가 호출되었는지 검증
expect(spy).toHaveBeenCalled();
// 'hello'라는 인자와 함께 호출되었는지 검증
expect(spy).toHaveBeenCalledWith('hello');
});
it('2. mockReturnValue를 사용하면 고정된 반환값을 가질 수 있다', () => {
// 어떤 인자가 들어와도 무조건 10을 반환하도록 설정
const mockAdd = vi.fn().mockReturnValue(10);
expect(mockAdd(1, 2)).toBe(10);
expect(mockAdd(100, 200)).toBe(10);
expect(mockAdd).toHaveBeenCalledTimes(2);
});
it('3. vi.fn(함수)를 사용하면 가짜 로직(Implementation)을 구현할 수 있다', () => {
// 인자를 받아 문자열을 조합하는 로직을 직접 주입
const mockOrder = vi.fn((item) => `${item} 주문 완료`);
expect(mockOrder('사과')).toBe('사과 주문 완료');
expect(mockOrder('포도')).toBe('포도 주문 완료');
// 두 번째 호출되었을 때의 인자가 '포도'인지 확인
expect(mockOrder).toHaveBeenLastCalledWith('포도');
});
});
▸ mockReturnValue: 결과값만 중요할 때 사용합니다. (예: API에서 데이터를 받아오는 척할 때)
▸ vi.fn(구현함수): 입력값에 따라 결과가 변해야 하거나, 실제 함수처럼 동작해야 할 때 사용합니다.
▸ 비동기 처리: 만약 Promise를 반환해야 한다면 mockResolvedValue(값)를 사용하면 편리합니다.
3. 실습: 고차 함수와 콜백 테스트
import { vi, describe, it, expect } from 'vitest';
/**
* 테스트할 함수: 상품 목록과 가격 계산 콜백을 받아 최종 가격 리스트를 반환합니다.
*/
const applyDiscount = (prices, calculate) => {
return prices.map(price => calculate(price));
};
describe('할인 시스템 테스트', () => {
it('모든 상품에 10% 할인이 정확히 적용된 값을 반환해야 한다', () => {
// 1. 가짜 함수 생성: 입력값의 10%를 뺀 값을 반환하도록 설정
const mockCalculator = vi.fn((price) => price * 0.9);
const itemPrices = [10000, 20000, 50000];
// 2. 실행
const results = applyDiscount(itemPrices, mockCalculator);
// 3. 검증 (Assertion)
// 호출 횟수: 상품이 3개이므로 3번 호출되었는가?
expect(mockCalculator).toHaveBeenCalledTimes(3);
// [반환값 검증 1] 특정 순서의 결과값 확인
// 10,000원 -> 9,000원 반환 확인
expect(mockCalculator).toHaveNthReturnedWith(1, 9000);
// 20,000원 -> 18,000원 반환 확인
expect(mockCalculator).toHaveNthReturnedWith(2, 18000);
// 50,000원 -> 45,000원 반환 확인
expect(mockCalculator).toHaveNthReturnedWith(3, 45000);
// [반환값 검증 2] 전체 결과 배열 확인
// applyDiscount가 최종적으로 내뱉는 배열 자체를 검증
expect(results).toEqual([9000, 18000, 45000]);
// [반환값 검증 3] 내부 mock 객체 데이터 확인 (디버깅 시 유용)
// mock.results에는 [{ type: 'return', value: 9000 }, ...] 형태로 저장됩니다.
expect(mockCalculator.mock.results[0].value).toBe(9000);
});
});
3. vi.mock(): 모듈 통째로 갈아끼우기 (The Replacement)
vi.fn()이 개별 함수를 조준하는 정밀 스나이퍼라면, vi.mock()은 특정 모듈(파일) 전체를 가짜로 대체해버리는 화력 지원과 같습니다.
1. 개념: vi.mock()이란?
드를 작성하다 보면 axios, firebase, 혹은 직접 만든 api.js 같은 외부 모듈을 import해서 사용하게 됩니다.
vi.mock()은 테스트 실행 직전, 실제 파일 대신 우리가 정의한 가짜 모듈을 import 하도록 강제로 가로채는 역할을 합니다.
2. 구조적 흐름과 호이스팅 (Hoisting)
vi.mock()에는 아주 중요한 특징이 있습니다. 바로 호이스팅(Hoisting)입니다.
Vitest는 테스트 파일을 실행하기 전에 vi.mock() 호출을 코드 최상단으로 끌어올립니다. 따라서 import 문 뒤에 vi.mock()을 써도, 실제로는 import가 일어나기 전에 Mocking이 완료됩니다.
동작 순서:
▸ vi.mock()이 코드 최상단으로 끌어올려짐 (Hoisting).
▸ 해당 모듈을 사용하는 모든 import 문이 가짜 모듈을 가리키도록 변경됨.
▸ 테스트 코드 실행.
3. 구체적인 실습 예시: API 서비스 모킹하기
// 실제 외부 통신 모듈: src/ch07/7-3-1.api.js
export const fetchData = async () => {
// 실제로는 DB나 외부 API에 접속하는 무거운 로직
const response = await fetch('/api/data');
return response.json();
};
// 테스트 대상 : src/ch07/7-3-1.user.service.js
import { fetchData } from './7-3-1.api';
export const getUserName = async () => {
const data = await fetchData();
return data.name;
};
// 테스트 코드: tests/ch07/7-3-1.Mock테스트.test.ts
import { getUserName } from '../../src/ch07/7-3-1.user.service.js';
import { fetchData } from '../../src/ch07/7-3-1.api.js';
import { vi, describe, it, expect } from 'vitest';
// 1. 모듈 통째로 모킹 (이 코드는 파일 최상단으로 호이스팅됩니다)
vi.mock('../../src/ch07/7-3-1.api', () => ({
fetchData: vi.fn(),
}));
describe('getUserName 테스트', () => {
it('fetchData가 반환한 객체에서 name을 정확히 추출해야 한다', async () => {
// vi.mocked를 사용하여 타입을 강제 변환합니다.
const mockedFetchData = vi.mocked(fetchData);
mockedFetchData.mockResolvedValue({ name: 'Gemini', id: 1 });
const name = await getUserName();
expect(name).toBe('Gemini');
expect(mockedFetchData).toHaveBeenCalledTimes(1);
});
});
✔️ 왜 fetchData.mockResolvedValue()가 타입 에러가 나는가?
vi.mock()으로 모듈을 mock 처리했더라도, TypeScript는 import된 함수의 타입을 “원래 구현 시그니처” 그대로 유지합니다.
즉, 런타임에서는 fetchData가 vi.fn()으로 대체되어 mock 함수이지만, 타입 시스템 입장에서는 여전히 다음과 같이 인식됩니다.
export async function fetchData(): Promise<any>;
그래서 테스트 코드에서
fetchData.mockResolvedValue({ name: 'Gemini', id: 1 });
를 작성하면 TypeScript는 다음과 같은 타입 오류가 발생합니다.
Property 'mockResolvedValue' does not exist on type '() => Promise<any>'.
중요한 점은, 이 오류는 런타임 에러가 아니라 순수한 타입 에러이며, 실제 실행 시에는 mock이 적용되어 정상 동작할 수도 있다는 점입니다.
//이 함수는 기존 타입을 Mock 타입으로 안전하게 캐스팅해 주는 역할을 합니다.
const mockedFetchData = vi.mocked(fetchData);
mockedFetchData.mockResolvedValue({ name: 'Gemini', id: 1 });
const name = await getUserName();
expect(mockedFetchData).toHaveBeenCalledTimes(1);
4. 부분모킹 : vi.importActual
모듈 내에 함수가 10개인데, 그중 1개만 모킹하고 나머지는 실제 로직을 쓰고 싶을 때가 있습니다. 이때 vi.importActual을 사용합니다.
// /tests/ch07/7-3-2.부분모킹테스트.test.ts
// 부분 모킹 설정
vi.mock('../../src/ch07/7-3-1.api', async () => {
// 1. 실제 모듈의 모든 내용을 가져옵니다.
//“actual의 타입을, ../../src/ch07/7-3-1.api 모듈을 import 했을 때의 타입과 동일하게 잡아줘
const actual = await vi.importActual<typeof import('../../src/ch07/7-3-1.api')>(
'../../src/ch07/7-3-1.api',
);
return {
...actual, // 실제 모듈의 모든 내보내기(export)를 복사
fetchData: vi.fn(), // fetchData만 가짜 함수로 대체
// getApiVersion은 명시하지 않았으므로 actual에 있는 실제 함수가 사용됨
};
});
describe('Partial Mocking 실습', () => {
it('fetchData는 모킹되어 가짜 값을 반환해야 한다 (getUserName)', async () => {
// fetchData를 Mock으로 타입 단언하여 설정
const mockedFetchData = vi.mocked(fetchData);
mockedFetchData.mockResolvedValue({ name: 'Partial-Mock-User' });
const name = await getUserName();
expect(name).toBe('Partial-Mock-User');
expect(mockedFetchData).toHaveBeenCalled();
});
it('getVersion은 모킹되지 않고 실제 값을 반환해야 한다 (getAppInfo)', () => {
// getAppInfo 내부의 getApiVersion은 실제 로직인 "v1.0.0"을 반환함
const info = getAppInfo();
expect(info).toBe('App Version: v1.0.0');
});
});
4. Mock 초기화와 관리 (Clean up)
테스트 케이스(it)가 여러 개일 때, 이전 테스트에서 함수가 호출된 횟수나 결과값이 다음 테스트에 그대로 남아있으면 테스트 오염(Test Pollution)이 발생합니다. Vitest는 이를 해결하기 위해 세 가지 초기화 메서드를 제공합니다.
1. 세 가지 초기화 메서드
| 메서드 | 호출기록(calls) | 가짜 구현 | 실제 로직 복구 |
| vi.clearAllMocks() | ✅ 초기화 | ❌ 유지 | ❌ 유지 |
| vi.resetAllMocks() | ✅ 초기화 | ✅ 초기화 (기본 mock 상태, undefined 반환) | ❌ 유지 |
| vi.restoreAllMocks() | ✅ 초기화 | ✅ 초기화 | ✅ 실제 로직으로 복구 |
▸ vi.clearAllMocks():
- "함수가 몇 번 불렸는지" 기록만 지웁니다. mockReturnValue로 설정한 가짜 로직은 유지됩니다.
▸ vi.resetAllMocks():
- 호출 기록뿐만 아니라 설정한 가짜 로직까지 모두 지웁니다. 다시 설정하지 않으면 undefined를 반환합니다.
▸ vi.restoreAllMocks():
- vi.spyOn()으로 만든 스파이를 실제 원래 함수로 되돌릴 때 사용합니다. (가장 강력한 초기화)
2. 실무 권장 설정
매번 테스트마다 수동으로 지우기 귀찮다면, vitest.config.ts 또는 테스트 파일 상단에 다음과 같이 설정하여 사용하면 됩니다.
// vitest.config.ts
export default defineConfig({
test: {
clearMocks: true, // 각 테스트 시작 전 호출 기록 자동 초기화
// 혹은 파일별로 beforeEach 사용
},
});
// 개별 테스트 파일에서
beforeEach(() => {
vi.clearAllMocks(); // 또는 resetAllMocks()
});
✔️ Vitest Axios Mocking Type Error: Property 'mockRejectedValue' does not exist on type
아래 코드와 같이 TypeScript 타입 추론을 위해 vi.mocked 사용 했음에도 오류가 발생할 수 있습니다.
// axios 모듈 구조를 직접 mock
vi.mock('axios');
// TypeScript 타입 추론을 위해 vi.mocked 사용
const mockedAxios = vi.mocked(axios);
// 아래 코드에서
// Property 'mockResolvedValue' does not exist on type 오류 발생
mockedAxios.get.mockResolvedValue({ data: { name: 'Kim', id: 1 } });
vi.mocked(axios)를 사용하더라도 default 객체 내부의 메서드까지 완벽하게 추론되지 않는 경우가 있습니다. 이럴 때는 vi.Mock 타입을 사용하여 직접 캐스팅하는 것이 가장 명확합니다.
// axios 모듈 구조를 직접 mock
vi.mock('axios');
// 1. axios.get을 Mock 함수 타입으로 명시적으로 정의
const mockedGet = vi.mocked(axios.get);
it('첫 번째 테스트', async () => {
// 이제 타입 추론이 정상적으로 작동합니다.
mockedGet.mockResolvedValue({ data: { name: 'Kim', id: 1 } });
const result = await fetchUserData(1);
expect(result.name).toBe('Kim');
});
3. 실습 예시
import { vi, describe, it, expect, beforeEach } from 'vitest';
import axios from 'axios';
import { fetchUserData } from '../../src/ch07/7-4-1.axios.service';
// 1. Axios 모듈 전체 모킹
// vi.mock('axios');
// axios 모듈 구조를 직접 mock
vi.mock('axios');
// TypeScript 타입 추론을 위해 vi.mocked 사용
const mockedAxiosGet = vi.mocked(axios.get);
describe('Axios Mocking & 초기화 실습', () => {
beforeEach(() => {
// 1. 호출 기록(calls)만 초기화 (구현은 유지)
vi.clearAllMocks();
// 2. 호출 기록 + 가짜 구현(Implementation) 모두 초기화
// vi.resetAllMocks();
});
it('첫 번째 테스트: 사용자 데이터를 성공적으로 가져온다', async () => {
// 가짜 구현 설정
mockedAxiosGet.mockResolvedValue({ data: { name: 'Kim', id: 1 } });
const result = await fetchUserData(1);
expect(result.name).toBe('Kim');
expect(mockedAxiosGet).toHaveBeenCalledTimes(1); // 호출 횟수: 1
});
it('두 번째 테스트: 호출 횟수가 초기화되었는지 확인한다', async () => {
// 가짜 구현 설정 (clearAllMocks 사용 시 이전 구현이 남아있어 다시 안 써도 동작함)
// 하지만 resetAllMocks 사용 시 아래 줄이 없으면 에러가 발생함
mockedAxiosGet.mockResolvedValue({ data: { name: 'Lee', id: 2 } });
const result = await fetchUserData(2);
expect(result.name).toBe('Lee');
/**
* [검증 포인트]
* beforeEach에서 vi.clearAllMocks()를 호출했기 때문에,
* 이전 테스트의 호출 기록이 지워져서 다시 1이 되어야 합니다.
*/
expect(mockedAxiosGet).toHaveBeenCalledTimes(1);
});
it('에러 상황 모킹 (Rejected Value)', async () => {
// 404 에러 상황 강제 발생
mockedAxiosGet.mockRejectedValue(new Error('User Not Found'));
await expect(fetchUserData(999)).rejects.toThrow('User Not Found');
});
});
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Vitest&TypeBox' 카테고리의 다른 글
| [TypeBox] 1편. Fastify에서 TypeBox를 사용하는 이유와 적용 방법 (0) | 2026.02.19 |
|---|---|
| [Vitest] 8편. Vitest + V8 기반 테스트 커버리지 : @vitest/coverage-v8 (0) | 2026.02.11 |
| [Vitest] 6편. MSW(Mock Service Worker) + Vitest 실무 API 테스트 (0) | 2026.02.06 |
| [Vitest] 5편. Vitest로 외부 API 테스트하기: axios, undici (0) | 2026.02.05 |
| [Vitest] 4편. Vitest로 Web API 테스트하기: Fastify inject() 활용법 (0) | 2026.01.30 |