Jest는 어떻게 import문을 mocking하는걸까? 2부
Jest 내부 코드를 따라가며 jest-runtime이 어떻게 require를 갈아끼우고 mock 여부를 판단하는지 살펴봅니다!
로딩중...
오래 걸리지 않기를 바라요..!
Jest 내부 코드를 따라가며 jest-runtime이 어떻게 require를 갈아끼우고 mock 여부를 판단하는지 살펴봅니다!
이 글은 Jest가 import문을 mock으로 교체하기까지의 과정을 담은 시리즈물입니다. 그 중에서 이번 글은 Jest 내부 코드를 따라가며 CJS 환경에서 mock이 어떻게 동작하는지를 다룹니다.
이전 글에서 Jest가 돌아가기 위한 (Node, CJS 모듈 시스템 등의) 배경 지식을 이야기했다.
그럼 어떻게 CJS 환경에서 Jest가 mock을 적용하는지를 알아보자.
1부 내용을 잠깐 리캡해보자. CommonJS에서 각 파일은 module wrapper 함수에 감싸져 실행되고, require는 문법이 아니라 wrapper가 인자로 넘겨주는 하나의 JS 함수였다. 결국 JS 위에서 동적으로 돌아가는 함수이기 때문에, require의 동작을 우리가 원하는 대로 조작할 수 있다는 허점까지 확인했다. 그리고 실제 모듈 대신 미리 기억해둔 mock을 전달하도록 require()를 조작할 수 있지 않을까라는 질문으로 글을 마무리했다.
그렇다면 Jest는 require를 어떻게 조작하고 있을까? Jest는 Node의 require.cache를 직접 건드리지 않는다. 자체 모듈 시스템인 jest-runtime을 두고, 그 안에 자기만의 레지스트리를 관리한다. Node의 require.cache 역할을 _moduleRegistry가 대신하고, mock으로 등록된 모듈은 별도의 _mockRegistry에서 꺼낸다.
그렇다면 어느 것을 모킹했는지, 안 했는지 등등 판단이 필요하고 그에 맞게 동작해야 할 텐데, 이를 위해서는 require 함수 자체가 우리의 의도대로 mock 모듈 혹은 진짜 모듈을 전달해야 한다.
1부에서 이야기했듯 require()는 일종의 지역 변수이자 함수이다. node app.js 와 같은 스크립트로 실행하면, Node의 모듈 로더가 wrapper 함수를 만들고 실행하게 된다.
이때 wrapper 함수가 제공하는 require()를 모킹할 수 있도록 조작하면 되지 않을까?
require() 를 조작하는 방법은 다양할 텐데, 여기서 Jest가 택한 방식은 wrapper 함수 자체를 조작하여 모듈에 있는 소스 코드 자체에는 아무런 영향을 주지 않는 방식이다. Node는 격리된 환경에서 코드를 돌릴 수 있는 vm 모듈을 제공한다.
node:vm 모듈은 분리된 V8 가상 머신 컨텍스트 환경 안에서 코드를 컴파일하고 실행할 수 있게 해준다.
vm 컨텍스트는 독립된 global 객체 위에서 코드를 실행한다.
우선 컨텍스트를 만들 수 있는데, vm.createContext(obj)를 호출하면 넘겨준 객체가 내부적으로 새 V8 컨텍스트와 연결되어, 하나의 전역 객체가 된다.
객체를 컨텍스트로 만든 뒤에는 vm.runInContext(code, obj)로 스트링 코드를 컴파일하고 실행한다.
const vm = require('node:vm');
const contextObject = { count: 0 };
vm.createContext(contextObject); // object를 v8 엔진과 연결
vm.runInContext('count += 1;', contextObject); // string으로 된 코드를 실행.
console.log(contextObject); // { count: 1 }독립적인 변수를 가지므로, 다른 vm과, 그리고 외부의 Local scope에 접근할 수 없다.
Jest는 각 테스트 파일을 별도의 컨텍스트에서 실행하면서, 그 컨텍스트에 자체 require와 module을 전역으로 채워둔다.
이렇게 함으로써 테스트 코드의 require가 Node의 것이 아니라 jest-runtime의 것으로 연결되고, 테스트 파일마다 컨텍스트가 분리되어 서로 오염되지 않는다.
일반적으로 터미널에 jest … 으로 테스트 코드를 실행하게 되면, jest-cli 패키지부터 실행된다.
이후 jest-config 패키지에서 설정 정규화를 진행한다. 그리고 파일을 탐색해 의존성 그래프(jest-haste-map)를 그리고, 실행할 테스트 파일과 실행 순서를 결정해 스케줄링(여기서 커스텀 러너를 붙이거나 다른 워커로 돌릴 수도 있다) 등의 작업을 진행하게 된다.
(해당 글에서는 파이프라인 전체를 다루지 않으며, jest-runtime 패키지의 일부에만 집중하였다)
이후에는 jest-runtime이 동작하며, 각 테스트 파일을 Node의 vm 모듈로 만든 독립된 컨텍스트에서 실행되도록 한다. jest-runtime은 그 컨텍스트 안에 자기만의 require를 주입해서, 진짜 모듈을 사용할지, jest.mock으로 등록된 가짜 모듈을 사용할지 결정한다.
파일별로 vm 컨텍스트를 따로 두는 이유는, 한 테스트 파일에서 만든 전역 상태나 mock 표시가 다른 파일로 새어 나가면 안 되기 때문이다. 전역을 공유하면 테스트끼리 서로 오염되고, 파일마다 mock 레지스트리를 따로 관리할 수도 없게 된다.
그렇다면 실제로 Jest가 커스텀 require를 주입하는 것을 확인해보자.
Jest의 코드는 여기서 확인이 가능하며, 코드의 이해를 위해 이 블로그 글을 참고했다. (Bending Jest to Our Will: Restoring Node's Require Behavior)
우선 RequireBuilder 클래스에서 require를 생성하는 로직을 확인할 수 있다.
재미있는 점은 테스트 라이브러리인 Jest에도 자신을 검증하는 테스트 코드가 존재한다는 것이다!
일반 Node의 require를 상속 및 오버라이딩 하지 않으며, 직접적으로 필요한 메서드들만 구현돼 있다. 그러면서 이전에 언급했던 require 함수에 필요한 몇 가지 속성들을 Jest 방식으로 채워주고 있다.
moduleRequire.extensions = Object.create(null);
moduleRequire.resolve = resolveImpl;
moduleRequire.cache = this.registries.createRequireCacheProxy();
Object.defineProperty(moduleRequire, 'main', {
enumerable: true,
value: this.testMainModule.current,
});resolve에는 모듈 경로를 찾는 함수가 채워진다.cache에는 Node의 실제 캐시가 아니라 createRequireCacheProxy로 만든 프록시가 들어간다.export class RequireBuilder {
// ...
constructor(options: RequireBuilderOptions) {
this.resolution = options.resolution;
this.registries = options.registries;
this.testMainModule = options.testMainModule;
this.requireDispatch = options.requireDispatch;
this.requireInternal = options.requireInternal;
}
//...
}RequireBuilder 생성자를 보면 requireDispatch 가 있다.
require는 생성자로 require 함수를 만드는 게 아니라 for() 메서드로 생성한다. require는 모듈마다(파일마다) 새롭게 생성되어야만 하므로 이런 패턴을 사용하는 것으로 보인다.
for(
from: InitialModule,
options: TransformOptions | undefined,
): NodeJS.Require {
const resolveImpl = (
moduleName: string,
resolveOptions?: ResolveOptions,
) => {
const resolved = this.resolve(from.filename, moduleName, resolveOptions);
if (
resolveOptions?.[JEST_RESOLVE_OUTSIDE_VM_OPTION] &&
options?.isInternalModule
) {
return createOutsideJestVmPath(resolved);
}
return resolved;
};
resolveImpl.paths = (moduleName: string) =>
this.resolvePaths(from.filename, moduleName);
const moduleRequire = (
options?.isInternalModule
? (moduleName: string) =>
this.requireInternal(from.filename, moduleName)
: (moduleName: string) =>
this.requireDispatch(from.filename, moduleName)
) as NodeJS.Require;
moduleRequire.extensions = Object.create(null);
moduleRequire.resolve = resolveImpl;
moduleRequire.cache = this.registries.createRequireCacheProxy();
Object.defineProperty(moduleRequire, 'main', {
enumerable: true,
value: this.testMainModule.current,
});
return moduleRequire; // require() 함수를 반환한다.
}require 함수 생성 시 isInternalModule 에 맞추어서 RequireBuilder 클래스의 생성자로 받았던 requireDispatch 를 호출한다. 즉 requireDispatch는 for() 메서드로 require를 생성할 때 호출되는 콜백과도 같다고 할 수 있다.
전체 코드: jest-runtime/src/index.ts
jest-runtime에서 vm을 만들고 환경 격리를 시킨다고 했고, vm을 만들 때 Jest만의 require() 를 만들어준다. 해당 코드를 확인해보자.

this.requireBuilder = new RequireBuilder({
registries: this.registries,
requireDispatch: (from, moduleName) =>
this.requireModuleOrMock(from, moduleName),
requireInternal: (from, moduleName) =>
this.requireInternalModule(from, moduleName),
resolution: this._resolution,
testMainModule: this.testMainModule,
});jest-runtime/src/index.ts 파일을 확인하면 Runtime 클래스 생성자 내에서 requireBuilder 인스턴스를 멤버로서 넣어주고 있는 것을 볼 수 있다.
이때 RequireBuilder가 for()로 require 함수를 만들 때 실행할 콜백인 requireDispatch 에 requireModuleOrMock 함수를 실행하는 로직을 넣는다.
requireModuleOrMock 함수를 따라가보자.

jest-runtime/src/index.ts#L477 (
Runtime클래스 내부에 정의된 메서드이다.)
코드를 살펴보면
requireModuleOrMock<T = unknown>(from: string, moduleName: string): T {
// ...환경이 종료됐는지, @jest/globals인지 확인하는 앞부분 생략...
try {
const {shouldMock, moduleID} = this.mockState.shouldMockCjs(
from,
moduleName,
);
if (shouldMock) {
return this._requireMockWithId<T>(from, moduleName, moduleID);
}
if (FRAMEWORK_SINGLETON_MODULES.has(moduleName)) {
return this.requireInternalModule<T>(from, moduleName);
}
return this.requireModule<T>(from, moduleName);
} catch (error) {
// ...모듈을 못 찾았을 때 에러 메시지를 다듬는 부분 생략...
}
}requireModuleOrMock 함수는 모듈에 맞추어서 mockState.shouldMockCjs 를 호출하여 mock을 가져와야 하는지, 혹은 실제 모듈을 가져와야 하는지를 판단한다.
MockState.ts#L63 여기에 가면 mock을 사용해야 하는지 그 여부를 확인하는 메서드들을 모아둔 것을 확인할 수 있다.
shouldMockCjs(from: string, moduleName: string): MockDecision {
const moduleID = this.resolution.getCjsModuleId(
this.virtualCjsMocks,
from,
moduleName,
);
return {
moduleID,
shouldMock: this.decideSync(from, moduleName, moduleID, 'cjs'),
};
}private decideSync(
from: string,
moduleName: string,
moduleID: string,
mode: 'cjs' | 'esm',
): boolean {
const explicitMap =
mode === 'cjs' ? this.explicitCjsMock : this.explicitEsmMock;
const explicit = explicitMap.get(moduleID);
if (explicit !== undefined) return explicit;
const key = transitiveCacheKey(from, moduleID);
if (
!this.shouldAutoMock ||
this.resolution.isCoreModule(moduleName) ||
this.shouldUnmockTransitiveDepsCache.get(key)
) {
return false;
}
const cached = this.shouldMockCache.get(moduleID);
if (cached !== undefined) return cached;
// ...manual mock 존재 여부, unmockList, 하위 의존성 처리...
}decideSync 메서드는 위에서부터 순서대로 판단한다.
첫째로, decideSync 메서드는 explicitCjsMock 맵을 확인한다. explicitCjsMock 맵은 해당 모듈이 mock을 사용해야 하는지에 대한 여부를 저장한다. moduleID 값을 키로, boolean 값을 값으로 가진다. (구현을 확인하고자 한다면 JestGlobals.ts 여기를 확인해라)
둘째로, explicitCjsMock 맵에 값이 없으면 decideSync 메서드는 자동 mock 대상인지 확인한다. shouldAutoMock 값이 false이거나, fs 같은 Node core 모듈이거나, 이미 unmock 처리된 하위 의존성이면 decideSync 메서드는 false를 반환하고 진짜 모듈을 쓰게 한다. 대부분의 프로젝트는 automock 설정을 켜지 않으므로, jest.mock을 호출하지 않은 모듈은 이 단계에서 진짜 모듈로 결정된다.
셋째로, 위 두 조건에 걸리지 않으면 shouldMockCache 맵을 확인한다. (캐싱 여부를 확인한다)
explicitCjsMock 맵에서 모듈의 mock 사용 여부를 기억하고 있다. 해당 맵은 어떻게 수정되는 걸까?
이 맵을 채우는 함수는 jest.mock과 jest.unmock 이다. jest.mock이라는 이름의 함수는 JestGlobals 클래스가 만드는 mock 함수이고, mock 함수는 factory 인자가 있는지에 따라 서로 다른 메서드를 부른다.
JestGlobals.ts 여기서 확인 가능하다.
먼저 mock의 구현을 보자.

const mock: Jest['mock'] = (moduleName, mockFactory, options) => {
if (mockFactory !== undefined) {
return setMockFactory(moduleName, mockFactory, options);
}
this.mockState.markExplicitCjsMock(from, moduleName);
return jestObject;
};// ...
markExplicitCjsMock(from: string, moduleName: string): void {
const moduleID = this.resolution.getCjsModuleId(
this.virtualCjsMocks,
from,
moduleName,
);
this.explicitCjsMock.set(moduleID, true);
}
// ...
setMock(from, moduleName, factory, options?): void {
// ...virtual 옵션 처리 생략...
const moduleID = this.resolution.getCjsModuleId(
this.virtualCjsMocks,
from,
moduleName,
);
this.explicitCjsMock.set(moduleID, true);
this.cjsFactories.set(moduleID, factory);
}
//...
const setMockFactory = (
moduleName: string,
mockFactory: () => unknown,
options?: {virtual?: boolean},
) => {
this.setMockBridge(from, moduleName, mockFactory, options);
return jestObject;
};markExplicitCjsMock() 메서드가 해당 explicitCjsMock 맵에 true 표시를 하는 것을 알 수 있다.
참고로 factory 사용 여부에 따라 분기하는 것을 확인할 수 있는데, factory 함수 사용은 아래와 같이 사용하는 것을 말한다.
jest.mock('./math', () => ({
add: jest.fn(() => 999),
}));이처럼 factory 함수를 사용하면 함수의 실행값을 넣어줄 수 있도록 mock을 설정하는데, 해당 글에서는 이 이상 깊이 있게 들어가지 않겠다.
unmock 또한 아래와 같이 explicitCjsMock 맵에 false로 표시하는 것을 알 수 있다.
const unmock = (moduleName: string) => {
this.mockState.unmockCjs(from, moduleName);
return jestObject;
};unmockCjs(from: string, moduleName: string): void {
const moduleID = this.resolution.getCjsModuleId(
this.virtualCjsMocks,
from,
moduleName,
);
this.explicitCjsMock.set(moduleID, false);
}그리고 이를 개발자가 테스트 파일에서 jest.mock과 jest.unmock 형식으로 사용할 수 있게 buildJestObject() 메서드로 jest 객체를 만드는 것을 확인할 수 있다.
지금까지 Jest의 동작을 정리하면 다음과 같다.
Jest의 모듈 모킹은 MockState 클래스가 가진 explicitCjsMock 맵을 사이에 두고 이뤄진다.
테스트 작성 시 mock으로 만들 의존성을 표시하는 jest.mock과 jest.unmock은 이 맵에 "해당 모듈은 mock으로 사용할 것이다"라는 표시를 남기게 된다. (true, false로)
Jest의 Runtime 클래스는 각 테스트 파일을 node:vm 격리 컨텍스트에서 실행하고, 그 컨텍스트의 전역 require를 자기 자신의 requireModuleOrMock 메서드로 연결한다. 테스트 코드가 부르는 require는 Node의 require가 아니라 Runtime 클래스의 requireModuleOrMock 메서드이다.
require('./math')가 실행되면 Runtime 클래스의 requireModuleOrMock 메서드가 MockState 클래스의 decideSync 메서드에게 explicitCjsMock 맵을 읽어 판단하게 한다. 맵의 값이 true이면 Runtime 클래스의 _requireMockWithId 메서드가 가짜 모듈을 돌려주고, false이면 Runtime 클래스의 requireModule 메서드가 진짜 파일을 실행한다.
explicitCjsMock 맵과 node:vm 격리 컨텍스트는 테스트 파일마다 따로 생성된다. 한 파일에서 남긴 mock 표시는 다른 파일의 판단에 영향을 주지 않는다.
지금까지 CJS 환경에서 Jest의 모킹이 어떻게 돌아가는지 알아보았다.
하지만 우리가 Jest를 사용하는 환경은 CJS만 있지는 않다. ESM 환경에서도 사용하고 있는데 이게 어떻게 가능한 걸까?
게다가 import 선언은 항상 모듈의 최상단으로 호이스팅되어, 코드가 실행되기 전에 의존성부터 먼저 로드된다. 그러면 jest.mock으로 mock을 사용하겠다는 표시 이전에 모듈을 불러오니, 실제 모듈을 가져오게 되는 것 아닐까?
이런 문제점들을 어떻게 Jest가 풀어나갔을까?
궁금하다면 다음 글로! → 3부 링크