Jest는 어떻게 import문을 mocking하는걸까? _3부
ESM 환경에서는 import를 런타임에 가로챌 수 없습니다. Jest가 babel-jest와 babel-plugin-jest-hoist로 이 문제를 우회하는 과정을 내부 코드와 함께 따라가 보았습니다!
로딩중...
오래 걸리지 않기를 바라요..!
ESM 환경에서는 import를 런타임에 가로챌 수 없습니다. Jest가 babel-jest와 babel-plugin-jest-hoist로 이 문제를 우회하는 과정을 내부 코드와 함께 따라가 보았습니다!
이 글은 Jest가 import문을 mock으로 교체하기까지의 과정을 담은 시리즈물입니다. 그 중에서 이번 글은 Jest 내부 코드를 따라가며 ESM으로 작성된 코드가 어떻게 CJS로 변환되어 mock이 적용되는지를 다룹니다.
이전 글에서 Jest가 어떤 원리로 돌아가는지 알아봤다. CommonJS의 require는 문법이 아니라 런타임에 호출되는 함수이고, Node는 각 파일을 하나의 wrapper 함수로 감싸 실행하며, 한 번 가져온 모듈은 캐시에 저장해 재사용한다. Jest는 이 원리를 차용해서, node:vm으로 만든 격리된 환경에 자신이 직접 만든 require 함수를 주입하는 방식으로 mocking이라는 목표를 이뤘다. → 이전 글
이 방식이 동작할 수 있었던 것은 결국 CommonJS 환경이었기 때문이다. 하지만 되돌아보면 나는 미션을 할 때 require 함수를 쓴 적이 없고, ESM(ECMAScript Modules) 환경에서 import / export를 사용했다. ESM 환경과 CJS의 차이점을 알아보고, 왜 ESM에서는 Jest를 그대로 돌릴 수 없는지, 그리고 Jest가 이를 우회해서 동작하는 방식을 알아보자.
이전 글에서 CommonJS를 다뤘다. CommonJS는 require 함수와 module.exports 객체로 모듈을 주고받는 방식이다. 그런데 우리가 테스트 파일을 실제로 작성할 때에는 require가 아니라 import와 export를 쓰는 게 훨씬 친숙할 것이다(개인적으로는..!).
// math.js
export function add(a, b) {
return a + b;
}// main.js
import { add } from './math';
add(1, 2);이 import와 export 문법을 쓰는 모듈 시스템이 ESM(ECMAScript Modules)이다. JavaScript 표준이 정한 공식 모듈 시스템이고, 최신 Node.js와 브라우저가 직접 실행할 수 있다. (JavaScript modules - MDN)
ESM의 import문은 파일이 실행되기 전에 정적으로 먼저 처리된다. JavaScript 엔진은 코드를 실행하기 전에 파일을 읽고, 어떤 모듈이 어떤 모듈을 가져오는지 연결 관계를 미리 확정한다. 이 연결 관계를 확정하는 작업을 링크라고 부른다. 즉 어떤 코드가 실제로 한 줄씩 실행되기 전에, import가 가리키는 모듈들은 이미 연결이 끝난 상태라는 거다.
이전 글에서도 다뤘지만, CommonJS의 require는 문법이 아니라 Node.js가 제공하는 지역 함수이다. 함수이기 때문에 코드가 실행되는 도중에 그 줄을 만났을 때 비로소 호출된다. 조건문 안에 넣거나 함수 안에서 부르는 것도 가능하다.
if (조건) {
const math = require('./math'); // 이 줄에 도달했을 때 실행
}여기서 ESM과 CJS의 차이점이 발생한다. ESM의 경우 실행 전에 정적으로 링크가 끝나기 때문에, 조건문 안에 넣거나 실행 도중에 연결을 바꿀 수 없다. (물론 동적 import() 문법을 사용할 수는 있다! 이번 글에서는 다루지 않겠다)
mocking을 하는 Jest의 입장에서 CommonJS의 require는 런타임에 호출되는 함수라서, 그 함수를 우리가 원하는 동작으로 바꿔치기하면 모듈을 가로챌 수 있다. 반면 ESM의 import는 실행 전에 링크가 끝나므로, 런타임에 끼어들어 다른 모듈로 바꿔치기할 자리가 없다.
실제 모듈이 아닌 mock을 사용하고 싶다면 jest.mock()으로 모킹할 수 있다. jest.mock()은 내부적으로 explicitCjsMock 맵에 해당 모듈을 true로 체크하고, 추후에 require로 모듈을 가져올 때 해당 모듈이 true라면 mock을, true가 아니라면 진짜 모듈을 사용하도록 분기 처리하는 방식이었다.
이 흐름은 require가 런타임에 호출되는 함수라는 점을 기대하며 동작한다. 그래야 require 함수가 실행되기 이전에 explicitCjsMock을 체크할 수 있고, require 함수 호출 시 explicitCjsMock의 값을 확인해서 mock 혹은 진짜 모듈을 가져올 수 있도록 하는 함수로 교체해 사용하도록 할 수 있기 때문이다.
그런데 개발자는 테스트 파일에 import를 사용한다. import는 정적으로 링크되어 런타임에 가로챌 수 없다. 만약 Jest가 import를 그대로 실행한다면, 런타임에 explicitCjsMock에 체크하지도 못하고, 모듈을 가져올 때 explicitCjsMock을 확인하지 못한다. jest.mock을 실행하기 이전에 이미 모듈을 가져왔기 때문이다.
어떻게 하면 ESM 환경에서 Jest를 돌아가게 할 수 있을까?
사실 ESM 환경에서는 JavaScript를 바탕으로 모듈 시스템을 거스르는 방식을 사용하기 어렵다. JavaScript로 로직을 작성한다는 것은 결국 런타임에 실행된다는 것인데, 이미 모듈 시스템은 이 런타임이 되기 이전에 모듈 작업을 마무리한다.
아쉽지만 ESM에서 돌아가는 mocking 코드를 작성하는 것은 매우 어렵다. 그러기에 CJS 환경에서만 돌리도록 구성하는 편이 마음 편할 것이다. Jest도 이러한 선택을 했다. ESM에서는 Jest가 돌아가지 않는다.
하지만, Jest는 이 문제를 ESM 환경 자체를 CJS로 바꾸는 방식으로 관점을 바꾸어 풀었다. 이때 사용되는 개념이 트랜스파일링(Transpilation)이다.
사실 ESM에서도 Jest를 사용할 수 있습니다! 이를 위해 실험적으로 jest.unstable_mockModule API를 제공하며, 아직 업데이트 중입니다. https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm
트랜스파일링은 실행 환경이 이해하지 못하는 코드를, 실행 환경이 이해할 수 있는 코드로 바꾸는 작업을 말한다. 어떤 언어로 작성된 소스 코드를 비슷한 추상화 수준의 다른 언어, 혹은 같은 언어의 다른 버전으로 변환하는 컴파일의 한 종류라고 볼 수 있다. (What is Babel? · Babel)
예를 들어서 JavaScript로 개발을 진행한다면, 브라우저마다 혹은 이외의 타 호스트마다 지원되는 JavaScript API들이 다를 수 있다. JavaScript의 최신 문법일수록 어긋날 가능성이 높아지는데, 대부분의 실행 환경이 이해할 수 있도록 최신 문법으로 작성된 JavaScript 코드를 동일한 동작을 하는 옛 문법으로 변경하는 작업이 필요하다. 이때 Babel과 같은 도구를 이용해 JavaScript를 트랜스파일링(Transpilation)한다.
JavaScript의 버전을 바꾸는 일 뿐만 아니라 ESM 기반의 코드를 CommonJS 형식으로도 변경이 가능하다.

(https://babeljs.io/repl에 들어가, 프리셋에서 env를 켜고 import 코드를 작성하면 변환된 코드가 나온다)
앞서 말했듯 CJS 기반 모듈 시스템에서만 돌아가는 Jest는 일반적으로 ESM으로 작성된 JavaScript 코드를 테스트하기 이전에 babel을 이용해 자신이 이해할 수 있는 코드로 변환하는 과정을 거친다.
앞서 jest-runtime 패키지가 require를 가로채서 mock과 진짜 모듈을 분기한다고 설명했다. 이런 분기가 동작하려면 테스트 파일의 import 문이 그 전에 require 호출로 바뀌어 있어야 한다. 이 변환을 담당하는 패키지가 babel-jest 패키지이다.
jest-runtime 패키지가 하나의 모듈을 로드하는 과정을 순서대로 적으면 다음과 같다.
import 코드를 require 코드로 변환한다.node:vm 격리 컨텍스트에서 실행한다.2부에서 본 Runtime 클래스는 모듈 실행을 ModuleExecutor 클래스에 맡기는데, exec 메서드를 보면 해당 흐름을 알 수 있다.
// ModuleExecutor 클래스의 exec 메서드 안
const transformedCode = this.transformCache.transform(filename, options); // 코드 변환 이후
const compiledFunction = this.compile(transformedCode, filename); // 컴파일을 진행한다.transformCache.transform 메서드가 소스 코드를 변환한 결과를 compile한다. compile 메서드에는 vm을 직접 만드는 코드가 있으며, 구현은 아래와 같다.
// ModuleExecutor 클래스의 compile 메서드 안
return compileFunction(
scriptSource, // 변환이 끝난 소스 코드 문자열
this.constructInjectedModuleParameters(), // 이 함수에 주입할 매개변수 이름 목록
{
filename: scriptFilename,
parsingContext: vmContext, // node:vm 격리 컨텍스트
// ...importModuleDynamically 등 생략...
},
);그렇다면 babel-jest 패키지가 import 문을 정확히 어떤 코드로 바꾸는지 직접 확인해보자.
아래는 테스트 파일에 흔히 쓰는 형태의 코드이다.
// input.js
import { add } from './math';
test('adds', () => {
expect(add(1, 2)).toBe(3);
});babel이 이 코드를 CommonJS 기반으로 변환하면 아래와 같이 바뀐다.
"use strict";
var _math = require("./math");
test('adds', () => {
expect((0, _math.add)(1, 2)).toBe(3);
});import { add } from './math' 한 줄이 var _math = require("./math")로 바뀐다. 그리고 원래 add를 직접 부르던 부분은 _math.add처럼 객체의 프로퍼티 접근으로 바뀌었다. named import였던 add가 사라지고, require가 돌려준 객체 _math에서 프로퍼티를 꺼내 쓰는 형태가 된다.
2부에서 jest-runtime 패키지가 require를 자기 것으로 교체해 mock과 진짜 모듈을 분기한다고 설명했다. 이 동작이 가능하게 하기 위해서는 CommonJS 환경에서 돌아갈 수 있도록 코드를 수정해야만 했고, 이를 위와 같이 babel로 vm을 만들기 이전에 수행하는 것이다.
변환 결과를 자세히 보면 문제가 하나 보일 것이다. 결과 코드에서 import가 require로 올바르게 바뀌었지만, 이렇게 바뀐 require 호출이 jest.mock 호출보다 먼저 실행된다는 점이 문제이다.
개발자는 보통 jest.mock 함수를 import 문 아래에 적는다. 코드를 위에서 아래로 읽는 순서대로라면 require 호출이 먼저 실행되고 jest.mock 함수가 나중에 실행된다. 이 순서대로 실행되면, explicitCjsMock 맵에 mock으로 쓰겠다는 표시(jest.mock 함수 호출)를 남기기 전에 require가 호출되어 실제 모듈을 가져와버리는 문제가 발생한다.
변환 전 코드가 아래와 같다면,
import { add } from './math'; // (1) 변환되면 require('./math')
jest.mock('./math'); // (2) mock 표시는 여기서 남는다코드에 적힌 순서대로 실행하면 (1)이 먼저라서 진짜 math 모듈이 로드된다. 하지만 개발자가 원하는 동작은 (2)의 mock 표시가 먼저 남고, 그다음에 require 호출이 그 표시를 보고 mock을 돌려주는 것이다. 실행 순서를 변경할 필요성이 있다.
이런 순서를 뒤집는 역할을 babel-plugin-jest-hoist 플러그인이 맡는다.
(소스 코드는 github.com/jestjs/jest/tree/main/packages/babel-plugin-jest-hoist에서 확인할 수 있다.)
babel-plugin-jest-hoist 플러그인은 jest.mock 호출을 같은 스코프의 require 호출보다 위로 끌어올린다.
동작 결과를 직접 확인해보자. babel-plugin-jest-hoist 플러그인의 README에 나온 방법대로, @babel/core의 transform 함수에 이 플러그인을 넘기면 변환 결과를 눈으로 확인할 수 있다. 아래처럼 패키지를 설치한다.
npm install --save-dev @babel/core @babel/preset-env babel-plugin-jest-hoist그다음 아래 스크립트를 작성해서 실행한다. import를 require로 바꾸는 변환까지 함께 보기 위해 @babel/preset-env를 같이 넘겼다.
// repro.js
const babel = require('@babel/core');
const code = `
import { add } from './math';
jest.mock('./math');
test('adds', () => {
expect(add(1, 2)).toBe(3);
});
`;
const result = babel.transform(code, {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
plugins: ['babel-plugin-jest-hoist'],
});
console.log(result.code);node repro.js로 실행하면 아래와 같은 변환 결과가 출력된다.
"use strict";
_getJestObj().mock('./math'); // 맨 위로 올라왔다
var _math = require("./math");
function _getJestObj() {
const { jest } = require("@jest/globals");
_getJestObj = () => jest;
return jest;
}
test('adds', () => {
expect((0, _math.add)(1, 2)).toBe(3);
});
소스에서는 import 문 아래에 있던 jest.mock('./math')이, 변환 후에는 require("./math")보다 위로 올라가 있다. 개발자가 코드에 적은 위치와 상관없이, 실제 실행 순서는 mock 표시가 먼저가 된다.
이런 끌어올리기가 언제 일어나는지는 babel-plugin-jest-hoist 플러그인의 소스 코드 주석에 적혀 있다.
// in `post` to make sure we come after an import transform and can unshift above the `require`s
post({path: program}) {정리하면, import 변환이 끝난 뒤에 동작해야 require 문들 위로 끌어올릴 수 있다는 것이다. import를 require로 바꾸는 변환이 먼저 끝나야, babel-plugin-jest-hoist 플러그인이 그 require 위에 jest.mock을 올릴 수 있다.
post 훅 안의 코드를 보면, babel-plugin-jest-hoist 플러그인은 블록마다 끌어올릴 문장을 모았다가 그 블록 맨 앞에 다시 넣는 방식으로 동작하는 것을 볼 수 있다.
BlockStatement: {
enter() {
stack.push({calls: [], vars: []});
},
exit(path) {
const item = stack.pop()!;
path.node.body.unshift(...item.vars, ...item.calls); // 블록 맨 앞에 다시 넣는다
},
},babel-plugin-jest-hoist 플러그인이 모아둔 jest.mock 호출문(calls)을 블록 본문의 맨 앞에 unshift로 넣기 때문에, 결과적으로 jest.mock 호출이 그 블록의 require 호출보다 위에 오게 된다.
호이스팅은 코드의 실행 순서를 바꾸는 만큼 주의해야 할 부분이 있다. Jest 공식 문서의 ES6 Class Mocks 페이지에 적힌 대로, 원래라면 동작해야 하는 코드의 실행 순서가 바뀌어, 선언하기 이전에 실행돼버리는 문제가 발생할 수 있다.

jest.mock 호출이 파일 맨 위로 끌어올려지기 때문에 선언 전에 접근하는 TDZ 문제가 생길 수 있다. 그러다 보니 먼저 변수를 정의한 뒤 factory 함수 안에서 그 변수를 쓰는 것은 불가능하다. 이 문제를 우회할 수 있도록 플러그인이 이름이 mock으로 시작하는 변수는 예외로 허용하므로, mock으로 시작하도록 변수 이름을 지으면 된다.
실제로 해당 문제가 있는지 아래처럼 factory 함수 안에서 바깥의 변수를 참조하도록 하여 어떻게 되는지 확인해보자.
// repro-error.js
const babel = require('@babel/core');
const code = `
const dependency = require('./real');
jest.mock('./math', () => ({
add: dependency.doSomething, // 바깥의 dependency를 참조한다
}));
`;
try {
babel.transform(code, { plugins: ['babel-plugin-jest-hoist'] });
} catch (e) {
console.log(e.message);
}node repro-error.js로 실행하면 아래와 같은 에러 메시지가 출력된다. 허용되는 이름 목록이 길어서 가운데는 줄였다.
The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables.
Invalid variable access: dependency
Allowed objects: AbortController, AbortSignal, ... jest, module, ... require, ... undefined.
Note: This is a precaution to guard against uninitialized mock variables.
If it is ensured that the mock is required lazily, variable names prefixed
with `mock` (case insensitive) are permitted.
2 | const dependency = require('./real');
3 | jest.mock('./math', () => ({
> 4 | add: dependency.doSomething,
| ^^^^^^^^^^
5 | }));
에러가 나는 이유는 호이스팅 때문이다. jest.mock 호출이 파일 맨 위로 끌어올려지는데, factory 함수 안에서 사용하고자 하는 dependency는 여전히 원래 위치에 있다. 끌어올려진 jest.mock 호출이 실행되는 시점에는 dependency가 아직 초기화되지 않았을 수 있다. 그래서 babel-plugin-jest-hoist 플러그인이 아예 변환 단계에서 막는다.
에러 메시지 마지막 줄에 나왔듯 변수 이름을 mock으로 시작하게 지으면 참조가 허용된다. 실제로 앞의 코드에서 변수 이름만 mockDependency로 바꾸면 변환이 통과한다.
const babel = require('@babel/core');
const code = `
const mockDependency = require('./real'); // mock 접두사를 붙였다
jest.mock('./math', () => ({
add: mockDependency.doSomething,
}));
`;
babel.transform(code, { plugins: ['babel-plugin-jest-hoist'] }); // 에러 없이 통과한다
실제로 이 규칙은 babel-plugin-jest-hoist 플러그인 소스 코드의 FUNCTIONS.mock 안, factory 함수가 참조하는 식별자를 검사하는 부분에 구현돼 있다.
let isAllowedIdentifier =
(scope.hasGlobal(name) && ALLOWED_IDENTIFIERS.has(name)) ||
/^mock/i.test(name) ||
// Allow istanbul's coverage variable to pass.
/^(?:__)?cov/.test(name);/^mock/i.test(name)이 변수 이름이 mock으로 시작하는지 검사하는 부분이다. i 플래그가 붙어 있어서 대소문자를 가리지 않는다. 이 조건이 참이면 isAllowedIdentifier 변수가 true가 되어, 바깥 변수 참조인데도 에러 없이 통과한다.
ALLOWED_IDENTIFIERS는 참조가 허용되는 이름을 모아둔 집합이다. 이 집합의 위에는 어떤 이름들이 허용되는지 설명하는 주석이 붙어 있다.
// We allow `jest`, `expect`, `require`, all default Node.js globals and all
// ES2015 built-ins to be used inside of a `jest.mock` factory.
// We also allow variables prefixed with `mock` as an escape-hatch.주석에 따르면 jest, expect, require 같은 이름과 Node.js 기본 전역, ES2015 내장 객체는 factory 함수 안에서 써도 된다고 한다. 그리고 마지막 줄에 mock 접두사를 붙인 변수는 예외로 허용한다고 적혀 있다. 개발자가 factory 함수 안에서 목 함수를 만들 때 이름을 mock으로 시작하게 지어야 한다는 공식 문서의 규칙을 여기서 확인할 수 있다.
이번 글에서는 Jest가 ESM 환경의 코드를 어떻게 다루는지 알아봤다.
ESM의 import 문은 실행 전에 정적으로 링크가 끝나기 때문에, 런타임에 끼어들어 다른 모듈로 바꿔치기할 자리가 없다. 반면 CommonJS의 require는 런타임에 호출되는 함수라서 바꿔치기가 가능하다. 그래서 Jest는 ESM 환경을 그대로 실행하지 않고, babel-jest 패키지로 import 문을 require 호출로 변환한 다음 CommonJS 환경에서 실행하는 방식을 택했다.
이 변환 과정에서 babel-plugin-jest-hoist 플러그인이 jest.mock 호출을 require 호출보다 위로 끌어올린다. 덕분에 개발자가 jest.mock 함수를 import 문 아래에 적어도, 실제 실행 순서는 mock 표시가 먼저 남고 그다음에 require 호출이 그 표시를 보고 분기하게 된다. 그리고 이런 호이스팅 때문에 factory 함수 안에서는 외부 변수를 참조할 수 없고, 참조하려면 변수 이름을 mock으로 시작하게 지어야 한다는 규칙도 함께 만들어졌다는 것을 알 수 있었다.
Jest는 CommonJS의 require가 런타임에 호출되는 함수라는 점을 이용해서 import 문을 mocking한다.
개발자가 테스트 파일에 import를 쓰면, babel-jest 패키지가 이 import 문을 require 호출로 변환한다. 이때 babel-plugin-jest-hoist 플러그인이 jest.mock 호출을 require 호출보다 위로 끌어올려서, mock을 사용하겠다는 표시가 모듈을 가져오는 시점보다 먼저 남도록 순서를 맞춘다.
jest-runtime 패키지는 각 테스트 파일을 node:vm 격리 컨텍스트에서 실행하면서, 그 컨텍스트에 Node의 require가 아닌 자기만의 require를 주입한다. 이렇게 주입된 require가 호출되면 Runtime 클래스의 requireModuleOrMock 메서드가 MockState 클래스의 explicitCjsMock 맵을 읽어서, 값이 true이면 가짜 모듈을 돌려주고 false이면 진짜 모듈을 실행하도록 분기한다.
결국 Jest는 import를 require로 바꾸고, 실행 순서를 맞추고, 격리 컨텍스트에 자기만의 require를 주입하는 여러 조각을 맞물려서 import 문을 mocking한다.
나는 지금껏 라이브러리를 이렇게까지 깊이 있게 파본 경험이 없었다. 이번에 "Jest는 어떻게 import문을 mocking하는걸까?"라는 하나의 질문에 답을 하기 위해서 필요한 개념들을 직접 찾아보고 공부하고, 또 코드를 까보고 실제로 돌려보면서 눈으로 보고, 그리고 손으로 직접 체험해봤다.
결과적으로 문제 해결 능력을 기룰 수 있었다. 단순히 내 문제(질문)의 답을 찾는 해결 능력 뿐만 아니라, 멋진 개발자들이 문제를 마주했을 때 풀어나가는 과정과 사고까지 어느 정도 엿보면서 문제 해결 능력을 기를 수 있었다.
Jest는 개발자가 작성한 실제 모듈 대신 가짜 모듈(mock)을 쓰게 만들어서, 외부 의존성에 흔들리지 않는 테스트를 돌리고 싶었다. 여기서 require는 런타임에 호출되는 함수라는 부분에서 정답을 찾아 require의 동작을 가로채 mock으로 바꿔치기하는 방식으로 이 문제를 해결해 나갔다.
그런데 정작 개발자들이 테스트 파일에 쓰는 건 require가 아니라 import였다. import는 실행 전에 정적으로 링크가 끝나버려서 런타임에 끼어들 자리가 없고, 게다가 jest.mock을 import 문 아래에 적으면 코드 순서상 mock 표시를 남기기도 전에 진짜 모듈이 먼저 로드되는 문제가 있다.
그래서 Jest는 babel을 통해 ESM으로 작성된 파일을 CJS에서 동일하게 돌아갈 수 있는 코드로 수정 후 돌린다. 이때 mock 표시를 먼저 체크하기 위해서 코드의 실행 순서 또한 수정한다.
내가 생각하는 최정상급 개발자는 이렇게 기술적인 문제를 지혜롭게 풀어낸다. 나도 나중에는 이런 개발자가 되겠지? 그러기 위해서는 실제로 이미 이 길을 걸어본 멋진 개발자들의 사고를 배워야 하고, 문제 해결 능력을 길러야 한다. 앞으로도 이런 활동을 많이 해보려 한다.