---
title: "vi.mock이 안 먹는 이유 — vitest isolate:false와 모듈 캐시"
description: "Vitest 실행 시 isolate 옵션이 꺼져 있으면 모듈 캐시가 공유되어 vi.mock이 무력화될 수 있습니다. 테스트 순서에 따라 결과가 달라지는 현상을 해결하려면 모듈 전체를 모킹하는 대신 vi.spyOn을 사용해 객체의 메서드만 교체하는 방식으로 가로채기 지점을 낮추는 것이 효율적입니다"
date: 2026-08-21
updated: 2026-08-27T02:54:17.847Z
tags: [vitest, testing, javascript, mocking]
canonical: https://blog.wooncloud.com/posts/vitest-isolate-false-vi-mock-module-cache
---

![닫힌 문 앞에 도착한 화살표](/images/posts/vitest-isolate-false-vi-mock-module-cache/978ff3e9-c49e-4f1f-9b4a-b71037f72c7c.webp)

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

## 상황

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

```ts
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`가 나가고 그게 실패한 것이다.

설정에는 이게 있었다.

```ts
// 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로 오해하기도 쉽다. 실제로는 완전히 결정적인 실패다 — 순서가 같으면 항상 같은 결과가 나온다.

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

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

```ts
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이 의심스러우면 이 두 가지를 비교한다.

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

## 참고

- [Vitest — isolate](https://vitest.dev/config/#isolate)
- [Vitest — vi.mock](https://vitest.dev/api/vi.html#vi-mock)
- [Vitest — vi.spyOn](https://vitest.dev/api/vi.html#vi-spyon)
