C.W.K.
Stream
Lesson 02 of 04 · published

Module Mock (그리고 Hoisting 함정)

~19 min · vitest-mocking, vi.mock, hoisting

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"vi.mock 은 너의 import 보다 먼저 돌아. 그거 이해하면 나머지는 기계적이야."

왜 module mock 이 존재해

가짜로 만들고 싶은 의존성이 인자로 넘길 수 있는 게 아닐 때가 있어 — 대상 코드가 직접 import 하는 모듈이야. fetchUserById./api 를 import 하고, 그게 fetch import 해서, 진짜 네트워크와 통신해. 다섯 층 통해 fake 를 꿰지 않고 "이 테스트 파일에서 `./api` 를 교체해" 라고 말하고 싶지.

vi.mock('./api') 가 그걸 해. Factory 없으면 모든 export 를 auto-mock 으로 교체 (각 함수가 vi.fn() 이 됨). Factory 있으면 교체를 명시적으로 제어.

Hoisting 함정

Vitest 가 로드 타임에 테스트 파일을 transform 하면서 모든 top-level vi.mock 호출을 맨 위로 — 어떤 import 보다도 앞으로 — 물리적으로 옮겨. import 가 진짜 모듈 말고 mock 을 봐야 하니까 필요해. 대가는 vi.mock 에 넘긴 factory function 이 파일의 모든 코드보다 먼저 돈다는 거. 테스트 파일의 변수, 헬퍼, 상수 참조 불가. 아직 존재하지 않거든.

Mock factory 와 테스트 본문 사이 데이터 공유가 필요하면 — 예를 들어 나중에 inspect 할 captured spy — vi.hoisted 로 그 데이터를 mock 옆에, 같이 hoist 되게, 같이 일찍 선언해.

`vi.mock` 안에서 테스트 파일 변수 close 하면 혼란스러운 에러로 실패해. 메시지가 보통 "Cannot access 'foo' before initialization" 같이 나와 — hoisted factory 가 아직 bind 안 된 이름에 손 뻗는 거야. 해법은 `vi.hoisted` 지 beforeAll 아냐.

부분 mock — 대부분 유지, 일부 교체

모듈에 export 열 개 있는데 하나만 교체하고 싶으면, factory 안에서 vi.importActual 써. 원본 모듈 반환해 — spread 하고 필요한 것만 오버라이드. 유틸 모듈에 맞는 움직임이야 — 다른 14 함수 그대로 두고 now() 만 교체.

테스트별 오버라이드 `mockResolvedValueOnce`

Module mock 은 default 로 파일 scope — 파일의 모든 테스트에 적용. 테스트마다 다른 반환값 필요하면 mock 된 함수 (이제 vi.fn()) 가 평소 queue 메서드 지원해. vi.mocked(realImport) 로 타입된 참조 얻고 타입 체커가 도와줄 거야.

Mock 항상 타입 지정. vi.mocked(fetchUser).mockResolvedValueOnce({ id: 1 }) 가 반환 모양에 자동완성 줘. 타입 없는 mock 은 쓰레기 반환을 조용히 허용해, 테스트가 버그 잡는 대신 쓰레기에 대해 통과해.

Code

대상 코드 — API 모듈을 import 하는 서비스·typescript
// src/services/api.ts
export async function fetchUserById(id: number) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error(`User ${id} not found`);
  return response.json();
}

// src/services/user-service.ts
import { fetchUserById } from './api';

export async function getDisplayName(id: number) {
  const user = await fetchUserById(id);
  return `${user.firstName} ${user.lastName}`;
}
Auto-mock + 타입 접근 `vi.mocked`·typescript
import { describe, it, expect, vi } from 'vitest';
import { getDisplayName } from './user-service';
import { fetchUserById } from './api';

// Hoisted to the top — runs before the imports above.
vi.mock('./api', () => ({
  fetchUserById: vi.fn(),
}));

// Get a typed handle to the mock.
const mockedFetch = vi.mocked(fetchUserById);

describe('getDisplayName', () => {
  it('combines first and last name', async () => {
    mockedFetch.mockResolvedValueOnce({ firstName: 'Pippa', lastName: 'Choi' });

    await expect(getDisplayName(1)).resolves.toBe('Pippa Choi');
    expect(mockedFetch).toHaveBeenCalledWith(1);
  });

  it('propagates fetch errors', async () => {
    mockedFetch.mockRejectedValueOnce(new Error('User 999 not found'));

    await expect(getDisplayName(999)).rejects.toThrow('User 999 not found');
  });
});
부분 mock — `importOriginal` 로 나머지 유지·typescript
// Partial mock — replace one export, keep the rest
import { describe, it, expect, vi } from 'vitest';
import { formatTimestamp, now } from './time-utils';

vi.mock('./time-utils', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./time-utils')>();
  return {
    ...actual,
    now: vi.fn(() => new Date('2026-01-01T00:00:00Z').getTime()),
  };
});

describe('formatTimestamp', () => {
  it('uses the mocked now() but real formatter', () => {
    // formatTimestamp uses now() internally, which is mocked.
    // The actual formatter logic still runs.
    expect(formatTimestamp()).toBe('2026-01-01T00:00:00.000Z');
  });
});
`vi.hoisted` — 공유 데이터의 escape hatch·typescript
// The hoisting trap and its fix
import { describe, it, expect, vi } from 'vitest';

// ❌ This fails — `mockUser` doesn't exist when the factory runs.
// const mockUser = { id: 1, name: 'Pippa' };
// vi.mock('./api', () => ({
//   fetchUserById: vi.fn().mockResolvedValue(mockUser), // ReferenceError
// }));

// ✅ Use vi.hoisted to declare data that the factory can reference.
const { mockUser, mockFetch } = vi.hoisted(() => ({
  mockUser: { id: 1, name: 'Pippa' },
  mockFetch: vi.fn().mockResolvedValue({ id: 1, name: 'Pippa' }),
}));

vi.mock('./api', () => ({
  fetchUserById: mockFetch,
}));

describe('with vi.hoisted', () => {
  it('shares data between the mock factory and the test', async () => {
    expect(mockFetch).toBeDefined();
    expect(mockUser.id).toBe(1);
  });
});

External links

Exercise

getUserGreeting(id) 함수 짜 — ./apifetchUserById(id) 호출해서 Hello, ${user.firstName}! 반환. 모듈을 두 가지 방식으로 mock 해: (1) auto-mock + 테스트별 mockResolvedValueOnce, (2) id 기반 커스텀 구현 반환하는 factory (id === 1 은 Pippa, id === 2 는 Dad). 둘 다 돌려서 통과 보고, 일부러 closure-over-variable 버그 도입해서 hoisting 에러 봐.
Hint
에러 메시지가 'Cannot access X before initialization' 이면 테스트 파일 scope 의 뭔가를 close 한 거야. 값을 factory 안으로 인라인하거나, vi.hoisted 로 옮겨.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.