본문으로 건너뛰기
wooncloud

vi.mock이 안 먹는 이유 — vitest isolate:false와 모듈 캐시

isolate: false 로 모듈 캐시가 파일 간에 공유되면, 다른 파일이 먼저 로드한 모듈에서는 vi.mock이 무력화됩니다. vi.spyOn으로 객체 메서드를 교체하면 해결됩니다.

·7 min read·— views··

테스트 파일 하나만 돌리면 전부 통과하는데, 전체를 돌리면 같은 테스트가 깨집니다. mock 문법을 아무리 들여다봐도 멀쩡합니다. isolate: false를 쓰고 있다면 범인은 문법이 아니라 모듈 캐시입니다.

상황

훅에 알림 분기를 하나 추가하고, 그 훅이 부르는 API 함수를 vi.mock으로 가로채는 테스트를 썼습니다.

vi.mock("../lib/api", async (importOriginal) => {
  const orig = await importOriginal<typeof import("../lib/api")>();
  return { ...orig, api: { ...orig.api, toggleStar: vi.fn().mockResolvedValue({}) } };
});

단독 실행: 7건 전부 통과. 인접 테스트 파일들과 함께 실행: 7건 중 6건 실패. 기대값은 "추가되었습니다"인데 "실패했습니다"가 나왔습니다. mock이 안 걸려서 진짜 fetch가 나가고 그게 실패한 것입니다.

설정에는 이게 있었습니다.

// vitest.config.ts
export default defineConfig({
  test: {
    isolate: false, // 속도를 위해 파일 간 격리를 껐다
  },
});

문제 — 로드가 끝난 모듈에는 지시할 수 없다

isolate: false는 테스트 파일마다 새 모듈 레지스트리를 만들지 않습니다. 실행 시간을 크게 벌어주지만, 모듈 캐시가 파일 사이에 공유됩니다.

그래서 이런 순서가 만들어집니다.

  1. 다른 테스트 파일이 먼저 useFileMutations.ts를 import 합니다. 이때 그 모듈은 진짜 api 객체를 바인딩한 채 평가되고 캐시에 남습니다.
  2. 제 파일이 vi.mock("../lib/api")을 호출합니다. 호이스팅되니 순서 자체는 맞습니다.
  3. 제 파일이 useFileMutations.ts를 import 합니다 — 그런데 이미 평가된 모듈이 캐시에서 그대로 옵니다. 재평가되지 않으니 mock한 api를 다시 바인딩할 기회가 없습니다.

vi.mock은 "이 모듈을 앞으로 로드할 때 대신 이걸 줘라"는 지시입니다. 로드가 이미 끝났으면 지시할 대상이 없습니다. await import()로 바꿔도 소용없습니다 — 캐시 히트는 마찬가지입니다.

고약한 점은 실패가 실행 순서에 의존한다는 것입니다. 파일 하나만 돌리면 초록불, CI에서 전체를 돌리면 빨간불입니다. 로컬에서 재현이 안 되니 flaky로 오해하기도 쉽습니다. 실제로는 완전히 결정적인 실패입니다 — 순서가 같으면 항상 같은 결과가 나옵니다.

해결 — 모듈 경계 대신 객체를 갈아끼운다

가로채는 지점을 한 단계 낮춥니다. 모듈이 무엇을 반환하느냐를 바꾸는 대신, 이미 존재하는 객체의 메서드를 교체합니다.

import { afterEach, beforeEach, vi } from "vitest";
 
import { api } from "../lib/api";
 
beforeEach(() => {
  vi.spyOn(api, "toggleStar").mockResolvedValue({});
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

캐시된 모듈이 참조하는 것도 결국 같은 api 객체입니다. 그 객체의 속성을 바꾸면 누가 언제 import 했든 보입니다. restoreAllMocks로 원본을 되돌리니 다른 파일로 새지도 않습니다.

전제가 두 가지 있습니다.

  • 대상 객체가 Object.freeze 되어 있지 않아야 합니다.
  • 호출부가 속성 접근으로 불러야 합니다. api.toggleStar(...)는 되지만, 모듈 최상단에서 const { toggleStar } = api로 미리 떼어놓으면 그 바인딩은 spy 이전 값이라 교체가 보이지 않습니다.

판별법

mock이 의심스러우면 이 두 가지를 비교합니다.

# 1) 파일 하나만
npx vitest run tests/my-feature.test.ts
 
# 2) 인접 파일들과 함께
npx vitest run tests/my-feature.test.ts tests/neighbor-a.test.ts tests/neighbor-b.test.ts
  • 1은 통과, 2는 실패 → 모듈 캐시 문제입니다. mock 문법은 건드릴 필요 없습니다.
  • 둘 다 실패 → 그때 문법을 봅니다.

isolate: true로 되돌리면 물론 해결되지만, 그 설정은 대개 실행 시간 때문에 의도적으로 꺼둔 것입니다. 스위트 전체의 격리를 되살리는 것보다 테스트 하나의 가로채기 지점을 낮추는 편이 비용이 훨씬 쌉니다.

정리

  • isolate: false는 모듈 캐시를 파일 간에 공유합니다.
  • 다른 파일이 대상 모듈을 먼저 로드했으면 vi.mock은 무력화됩니다 — 재평가가 일어나지 않기 때문입니다.
  • 증상은 "혼자 돌리면 통과, 같이 돌리면 실패"입니다. flaky가 아니라 순서 의존입니다.
  • vi.spyOn(obj, "method") + afterEach(vi.restoreAllMocks)는 캐시와 무관하게 동작하고 누출도 없습니다.

참고