본문으로 건너뛰기
wooncloud

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

Vitest 실행 시 isolate 옵션이 꺼져 있으면 모듈 캐시가 공유되어 vi.mock이 무력화될 수 있습니다. 테스트 순서에 따라 결과가 달라지는 현상을 해결하려면 모듈 전체를 모킹하는 대신 vi.spyOn을 사용해 객체의 메서드만 교체하는 방식으로 가로채기 지점을 낮추는 것이 효율적입니다

·6 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)는 캐시와 무관하게 동작하고 누출도 없다.

참고