
2부에서는 가장 고민이 많았던 모킹(Mocking) 시스템 구현에 대해 다룰 예정이다.
모킹은 외부 의존성을 가짜 구현으로 대체하여 테스트를 독립적으로 만드는 핵심 기능이다. JavaScript의 ESM(ES6 Modules)은 정적으로 분석되고 캐싱되기 때문에 런타임에 모듈을 변경하는 것이 불가능하다.
따라서 Babel을 이용해 mock이 등록된 모듈은 원본 대신 mock 객체를 가져오도록 import 구문을 변환하는 방식으로 구현했다.
Jest를 사용하며 정말 많이 사용하게 되는 기능이 모킹 기능이었다.
그럼 모킹은 어떤 경우에 사용하게 될까?
예를 들어 아래와 같은 랜덤한 값을 반환하는 함수를 테스트한다고 해보자.
Math.random()은 매번 다른 값을 반환하므로 예측이 불가능하다// random.js
export const random = () => Math.random();
// game.js
import { random } from './random.js';
export const play = () => random() * 10;
// main.test.js
test('play returns value', () => {
const result = play();
expect(result).toBe(???); // 예측 불가능!
});
바로 이런 경우 모킹이 필요하다.
random을 모킹하여 우리가 예측 할 수 있는 값을 리턴하게 만들어
테스트가 가능하게 만들 수 있다.
import { random } from './random.js';
그럼 Jest는?
Jest는 ESM과 CommonJS 방식을 모두 지원하지만, 내부적으로는 CommonJS 방식을 사용하는 것으로 보인다.
그럼 뭐가 다른 걸까?
require()는 런타임에 실행되며, Jest는 이 시점에 모듈을 가로채서 mock으로 교체한다.
반면 정적 import는 코드 실행 전에 모듈이 미리 로드되기 때문에 런타임에 가로채는 것이 불가능하다.
핵심 아이디어는 간단하다.
// 변환 전
import { random } from './random.js';
// 변환 후
const _module = mockStore.has('/absolute/path/random.js')
? mockStore.get('/absolute/path/random.js')
: await import('./random.js');
const { random } = _module;
앞선 해결 전략에 따라 mock을 구현하려면 우선 mock들을 저장할 Store가 필요하다.
구현 로직
mockStore: Map 자료구조로 모듈 경로를 key, mock 객체를 value로 저장// store.js
export const mockStore = new Map();
export function mock(modulePath, mockExports) {
mockStore.set(modulePath, mockExports);
}
export function clearAllMocks() {
mockStore.clear();
}
export function unmock(modulePath) {
mockStore.delete(modulePath);
}
export function isMocked(modulePath) {
return mockStore.has(modulePath);
}
이제 Babel을 이용해서 import문을 찾아 변환해야 한다.
기본 구조
babelTransformImportdynamicImportawait import() 구문을 Babel이 처리할 수 있게 해주는 플러그인전체 구조
// cli.js
const transformFile = (filePath) => {
const originalCode = fs.readFileSync(filePath, 'utf-8');
originalFiles.set(filePath, originalCode);
const transformed = transformSync(originalCode, {
filename: filePath,
plugins: [babelTransformImport],
parserOpts: {
sourceType: 'module',
plugins: ['dynamicImport']
}
});
fs.writeFileSync(filePath, transformed.code);
};
// babelTransformImport.js
export const babelTransformImport = ({ types: t }) => {
return {
visitor: {
Program(path) {
// 1. mockStore import 자동 추가
},
ImportDeclaration(nodePath, state) {
// 2. import 구문을 조건부 동적 import로 변환
}
}
};
};
Program? ImportDeclaration?babelTransformImport 함수의 구조를 처음 보면 모든 게 의아할 것이다.
이 함수는 기본적으로 visitor 패턴을 따르고 있으며, Babel 객체를 인자로 받는다.
그럼 visitor는 무엇일까?
visitor는 AST(Abstract Syntax Tree)의 각 노드를 순회하며, 특정 타입의 노드를 만났을 때 어떻게 처리할지 정의하는 부분이다.
Program: 파일 전체를 나타내는 루트 노드, 최초 1회 실행ImportDeclaration: import 문을 만나면 실행VariableDeclaration: let, const, var를 만나면 실행IfStatement: if문을 만나면 실행ForStatement: for문을 만나면 실행이제 이 내용을 바탕으로 하나씩 구현해보자.
모든 파일 최상단에 mockStore를 자동으로 import한다.
Program(path) {
const importStatement = t.ImportDeclaration(
[t.importSpecifier(
t.identifier('mockStore'),
t.identifier('mockStore')
)],
t.stringLiteral('@dannysir/js-te/src/mock/store.js')
);
path.node.body.unshift(importStatement);
}
결과
// 자동 추가됨
import { mockStore } from '@dannysir/js-te/src/mock/store.js';
우선 하나씩 설계해보자.
ImportDeclaration의 구조를 살펴보면 다음과 같다.
주요 변수
source: 기존 import문의 경로specifiers: import 항목들이 담긴 배열// 기존 import문
import { play } from './game.js';
import { play as gamePlay } from './game.js';
// AST 내부 specifiers 구조
specifiers: [
{
type: 'ImportSpecifier',
imported: { name: 'play' },
local: { name: 'play' }
},
{
type: 'ImportSpecifier',
imported: { name: 'play' },
local: { name: 'gamePlay' }
}
]
변환에 필요한 변수들
moduleDeclaration: 새롭게 만든 import문 (3항 연산자 + 동적 import문)extractDeclarations: 선언된 모듈들을 구조 분해 할당하기 위한 배열extractDeclaration: 각각의 모듈을 구조 분해 할당하여 선언전체 로직 구조
ImportDeclaration(nodePath, state) {
const source = nodePath.node.source.value;
// mockStore import는 건드리지 않음
if (source === '@dannysir/js-te/src/mock/store.js') {
return;
}
// 1. 조건부 import 생성
const specifiers = nodePath.node.specifiers;
const moduleVarName = nodePath.scope.generateUidIdentifier('module');
const moduleDeclaration = t.variableDeclaration('const', [
// 3단계에서 구현
]);
// 2. 개별 변수 추출
const extractDeclarations = specifiers.map(spec => {
// 4단계에서 구현
});
const extractDeclaration = t.variableDeclaration('const', extractDeclarations);
// 3. 원본 import를 변환된 코드로 교체
nodePath.replaceWithMultiple([moduleDeclaration, extractDeclaration]);
}
이제 하나씩 구현해보자.
전체 코드를 한 번에 보면 복잡해 보이니 단계별로 살펴보자.
구현 단계
module이라는 이름의 고유 변수 생성mockStore.has(경로)mockStore.get(경로)await import(경로)const moduleDeclaration = t.variableDeclaration('const', [
t.variableDeclarator(
moduleVarName,
// 3항 연산자 생성
t.conditionalExpression(
// 조건: mockStore에 해당 경로가 있는지 확인
t.callExpression(
t.memberExpression(t.identifier('mockStore'), t.identifier('has')),
[t.stringLiteral(source)]
),
// 참일 때: mockStore에서 mock 객체 가져오기
t.callExpression(
t.memberExpression(t.identifier('mockStore'), t.identifier('get')),
[t.stringLiteral(source)]
),
// 거짓일 때: 원본 모듈 동적 import
t.awaitExpression(
t.importExpression(t.stringLiteral(source))
)
)
)
]);
출력 예시
const _module = mockStore.has('./random.js')
? mockStore.get('./random.js')
: await import('./random.js');
이제 import한 모듈에서 필요한 항목들을 추출해야 한다.
Import 방식에는 세 가지 타입이 있다:
import { random } from './random.js'import random from './random.js'import * as random from './random.js'각 타입에 맞게 처리한다.
// 개별 변수 추출
const extractDeclarations = specifiers.map(spec => {
let importedName, localName;
// 1. Default Import 처리
if (t.isImportDefaultSpecifier(spec)) {
importedName = 'default';
localName = spec.local.name;
}
// 2. Namespace Import 처리 (import * as name)
else if (t.isImportNamespaceSpecifier(spec)) {
localName = spec.local.name;
return t.variableDeclarator(
t.identifier(localName),
moduleVarName // 전체 모듈을 그대로 할당
);
}
// 3. Named Import 처리
else {
importedName = spec.imported.name;
localName = spec.local.name;
}
return t.variableDeclarator(
t.identifier(localName),
t.memberExpression(moduleVarName, t.identifier(importedName))
);
});
const extractDeclaration = t.variableDeclaration('const', extractDeclarations);
출력 예시
// Named Import
const { random } = _module;
// Default Import
const { default: random } = _module;
// Namespace Import
const random = _module;
마지막으로 원본 import문을 새로 생성한 코드로 교체한다.
// 원본 import를 변환된 코드로 교체
nodePath.replaceWithMultiple([moduleDeclaration, extractDeclaration]);
replaceWithMultiple은 하나의 노드를 여러 개의 노드로 교체할 때 사용한다.
변환 전
import { random } from './random.js';
변환 후
import { mockStore } from '@dannysir/js-te/src/mock/store.js';
const _module = mockStore.has('./random.js')
? mockStore.get('./random.js')
: await import('./random.js');
const { random } = _module;
이제 import문이 실행될 때마다
이렇게 동작하게 된다
이제 사용자는 다음과 같이 모킹을 사용할 수 있다.
// random.js
export const random = () => {
return Math.floor(Math.random() * 10);
}
// game.js
import { random } from './random.js'
export const play = () => {
return random() * 10;
}
// game.test.js
test('[mocking] - mocking random function', async () => {
mock('./random.js', {
random: () => 3,
});
const { play } = await import('./game.js');
expect(play()).toBe(30);
});
동작 과정
mock('./random.js', { random: () => 3 })로 mock 등록game.js를 import하면 Babel이 코드를 변환game.js 내부의 import { random }이 실행될 때 mockStore 확인'./random.js'가 등록되어 있으므로 random: () => 3 사용play()는 3 * 10 = 30 반환그런데 여기서 한 가지 문제가 생긴다.
사용자가 상대 경로를 이용할 경우
예시로 살펴보자
project/
├── src/
│ ├── utils/
│ │ └── random.js
│ └── game.js
└── test/
└── game.test.js
// game.test.js에서 mock 등록
mock('./random.js', { random: () => 3 });
// Map에 './random.js'로 저장됨
// game.js에서 import
import { random } from '../utils/random.js';
// Babel이 '../utils/random.js'로 변환
// Map에서 './random.js'를 찾지 못함!
결과: Mock이 적용되지 않고 원본 모듈이 import된다.
이 문제를 해결하기 위해 모든 경로를 절대 경로로 통일하기로 했다.
해결 전략
Babel 플러그인에 다음 로직을 추가한다.
ImportDeclaration(nodePath, state) {
const source = nodePath.node.source.value;
if (source === '@dannysir/js-te/src/mock/store.js') {
return;
}
// 🔥 절대 경로 변환 추가
const currentFilePath = state.filename || process.cwd();
const currentDir = path.dirname(currentFilePath);
let absolutePath;
if (source.startsWith('.')) {
// 상대 경로 → 절대 경로 변환
absolutePath = path.resolve(currentDir, source);
} else {
// npm 패키지는 그대로
absolutePath = source;
}
// 이제 absolutePath를 사용하여 mockStore 체크
const moduleDeclaration = t.variableDeclaration('const', [
t.variableDeclarator(
moduleVarName,
t.conditionalExpression(
t.callExpression(
t.memberExpression(t.identifier('mockStore'), t.identifier('has')),
[t.stringLiteral(absolutePath)] // 절대 경로 사용
),
t.callExpression(
t.memberExpression(t.identifier('mockStore'), t.identifier('get')),
[t.stringLiteral(absolutePath)] // 절대 경로 사용
),
t.awaitExpression(
t.importExpression(t.stringLiteral(source)) // 원본 경로로 import
)
)
)
]);
// ... 나머지 코드
}
변환 결과
// game.js에서 이렇게 작성해도
import { random } from '../utils/random.js';
// Babel이 이렇게 변환
const _module = mockStore.has('/Users/san/project/src/utils/random.js')
? mockStore.get('/Users/san/project/src/utils/random.js')
: await import('../utils/random.js');
이제 mock 등록과 Babel 변환 모두 동일한 절대 경로를 사용하여 정확히 매칭된다!
이제 플러그인 개발을 완료했다.
그럼 CLI 부분 로직을 수정해야 한다.
전체 흐름
const originalFiles = new Map();
// 1. 파일 변환 함수
const transformFile = (filePath) => {
const originalCode = fs.readFileSync(filePath, 'utf-8');
originalFiles.set(filePath, originalCode); // 원본 저장
const transformed = transformSync(originalCode, {
filename: filePath,
plugins: [babelTransformImport],
parserOpts: {
sourceType: 'module',
plugins: ['dynamicImport']
}
});
fs.writeFileSync(filePath, transformed.code);
};
// 2. 원본 복구 함수
const restoreFiles = () => {
for (const [filePath, originalCode] of originalFiles.entries()) {
fs.writeFileSync(filePath, originalCode);
}
};
전체 코드 흐름
// 1. 모든 소스 파일 찾기 및 변환
const sourceFiles = findAllSourceFiles(process.cwd());
for (const file of sourceFiles) {
transformFile(file);
}
// 2. 테스트 파일 찾기 및 변환
const testFiles = findTestFiles(process.cwd());
console.log(`\\nFound ${testFiles.length} test file(s)`);
// 3. 테스트 실행
for (const file of testFiles) {
console.log(`\\n${file}\\n`);
transformFile(file);
await import(path.resolve(file));
const { passed, failed } = await jsTe.run();
totalPassed += passed;
totalFailed += failed;
}
// 4. 원본 복구 (중요!)
restoreFiles();
console.log(`\\nTotal: ${totalPassed} passed, ${totalFailed} failed`);
if (totalFailed > 0) {
process.exit(1);
}
왜 원본 복구가 중요할까?
이제 테스트 코드에서 절대 경로로 등록하면, 사용자의 코드에서 어떻게 사용해도 모킹이 정상적으로 동작한다.
// random.js
export const random = () => {
return Math.floor(Math.random() * 10);
}
// game.js
import { random } from './random.js'
export const play = () => {
return random() * 10;
}
// game.test.js
test('[mocking] - mocking random function', async () => {
// 절대 경로로 mock 등록
mock('/Users/san/Js-Te/test-helper/random.js', {
random: () => 3,
});
const { play } = await import('./game.js');
expect(play()).toBe(30);
});
주의 사항
await import()) 사용 필요