
4부에서는 Rollup을 이용해 ESM과 CommonJS를 모두 지원하는 듀얼 패키지를 만들었다.
이전 포스트까지가 v0.2.x 버전의 주요 변경 사항이었다면, 이번 포스트부터는 v0.3.x 버전의 주요 변경 사항을 살펴볼 것이다.
"mock을 등록한 후에 반드시 동적 import를 해야 한다"
// 이렇게 해야만 동작함 😢
test('mocking test', async () => {
mock('/absolute/path/random.js', {
random: () => 3
});
// mock 이후에 동적 import 필수!
const { play } = await import('./game.js');
expect(play()).toBe(30);
});
Jest는 import를 최상단에 작성해도 모킹이 잘 동작하는데, 왜 내 라이브러리는 불가능할까?
이번 포스트에서는 이 문제를 분석하고 Wrapper 패턴으로 해결하는 과정을 다루려고 한다.
현재 구조를 다시 살펴보자.
// game.js
import { random } from './random.js';
export const play = () => {
return random() * 10;
};
// game.test.js
import { play } from './game.js'; // ❌ 이 순간 이미 로드됨
test('mocking test', () => {
mock('/path/random.js', { random: () => 3 });
// 이미 로드된 game.js는 원본 random을 참조 중
expect(play()).toBe(30); // 실패
});
문제의 핵심
game.js가 로드되고, game.js 내부의 random.js도 로드됨현재 해결책 - 동적 import
test('mocking test', async () => {
mock('/path/random.js', { random: () => 3 });
// 이 시점에 처음으로 game.js 로드
const { play } = await import('./game.js');
expect(play()).toBe(30); // 성공
});
하지만 이 방식은 마음에 들지 않는다.
async/await 작성 필요cmd + enter 로 import문을 바로 생성하는데 현재는 사용성이 떨어짐Jest의 동작을 살펴보면
// Jest에서는 이게 가능함
import { play } from './game.js';
jest.mock('./random.js', () => ({
random: () => 3
}));
test('mocking test', () => {
expect(play()).toBe(30); // 성공
});
Jest의 해결 전략
Jest는 mock 호이스팅(hoisting)을 사용한다.
💡 참고: Jest의 babel-plugin-jest-hoist
- 테스트 파일을 분석하여 모든
jest.mock()호출을 찾음- 코드 실행 전에 mock을 먼저 등록
- 이후 import가 실행될 때 이미 mock이 준비된 상태
// 실제 실행 순서
// 1. jest.mock() 호출 (코드 최상단보다 먼저!)
// 2. import 문 실행
// 3. 테스트 함수 실행
그렇다면 Jest처럼 mock 호이스팅을 구현하면 될까?
물론 호이스팅 기능을 구현하면 import문을 최상단에 사용할 수 있을 것이다.
그러나 호이스팅을 이용하면 또 다른 오류가 생기는 것을 예상할 수 있다.
문제 상황
현재 우리 라이브러리에서 mock은 각각의 test 내부에서 선언되어 하나의 테스트 내에서만 영향을 끼치고 있다.
describe('Tests', () => {
test('test 1', () => {
mock('/path/random.js', { random: () => 3 });
// test 1에서만 mock 적용
});
test('test 2', () => {
// test 2는 원본 random 사용
// test 1의 mock 영향 받지 않음
});
});
그러나 만약 mock이 호이스팅되어 사용되면
// mock이 호이스팅되면
mock('/path/random.js', { random: () => 3 }); // 최상단으로 이동
import {random} from '/path/random.js'; // mock 이후에 import
describe('Tests', () => {
test('test 1', () => {
// mock 적용됨
});
test('test 2', () => {
// ❌ 의도하지 않았지만 mock이 적용됨
});
});
문제의 핵심
Jest의 경우 이를 해결하기 위해
Mock 호이스팅 방법을 찾다가 문득 한 가지 생각이 들었다.
"모듈을 함수로 감싸서 호출 시마다 mockStore를 확인하게 만들면 어떨까?"
만약 모듈을 특정 패턴으로 감싸서 wrapper 함수로 만들면,
함수가 실행될 때마다 mockStore를 검사하여 상황에 맞는 모듈을 반환하게 만들 수 있지 않을까?
기존 방식
// 변환 결과
const _module = mockStore.has('/path/random.js')
? mockStore.get('/path/random.js')
: await import('./random.js');
const { random } = _module;
// 문제: import 시점에 mockStore를 한 번만 체크
새로운 방식
// 변환 결과
const _original = await import('./random.js');
const random = (...args) => {
const module = mockStore.has('/path/random.js')
? { ..._original, ...mockStore.get('/path/random.js') }
: _original;
return module.random(...args);
};
// random() 호출 시마다 mockStore를 체크
// 추가적으로 테스트 실행마다 mockStore를 초기화하는 로직 필수
차이점
// 1. 파일 로드 시점
import { random } from './random.js';
// ↓ Babel 변환
const _original = await import('./random.js'); // 원본 저장
const random = (...args) => { // wrapper 함수 생성
// 이 부분은 아직 실행 안 됨
};
// 2. 테스트 시작
test('test', () => {
// 3. mock 등록
mock('/path/random.js', { random: () => 3 });
// 4. random() 호출
const result = random(); // wrapper 함수 실행
// ↓
// wrapper 내부에서 mockStore 체크
// mockStore에 '/path/random.js'가 있음
// mock 함수 사용: () => 3
expect(result).toBe(3); // 성공
});
테스트가 분리되어 실행됨
describe('Tests', () => {
test('test 1', () => {
mock('/path/random.js', { random: () => 3 });
const result = random(); // wrapper가 mockStore 체크 → mock 사용
expect(result).toBe(3); // ✅ 성공
});
test('test 2', () => {
// mock 등록 안 함
const result = random(); // wrapper가 mockStore 체크 → 원본 사용
expect(result).toBe(???); // ✅ 원본 random() 실행
});
});
각 테스트가 실행될 때마다 wrapper 함수가 mockStore를 확인하므로
테스트 간 모킹이 별도로 유지된다
그럼 이제 앞서 설계한 Wrapper 패턴에 맞게 Babel 플러그인을 구현해야 한다.
그러나 해당 과정은 이전에 작성했던 2부 포스트의 Babel 플러그인 구현하기와 거의 동일한 과정이기 때문에, 이번에는 변환 결과와 동작 원리 위주로 설명하겠다.
😊 전체 Babel 플러그인 코드는 GitHub 레포지토리를 참고
이전 방식
// import 시점에 한 번만 체크
const _module = mockStore.has('/path')
? mockStore.get('/path')
: await import('./random.js');
const { random } = _module;
새로운 방식
// 함수 호출 시마다 체크
const _original = await import('./random.js');
const random = (...args) => {
const module = mockStore.has('/path')
? { ..._original, ...mockStore.get('/path') }
: _original;
return module.random(...args);
};
핵심 차이점
_original에 먼저 저장변환 전
import { random } from './random.js';
import { add, subtract } from './math.js';
import * as utils from './utils.js';
변환 후
const mockStore = global.mockStore;
const _original = await import('./random.js');
const random = (...args) => {
const module = mockStore.has('/absolute/path/random.js')
? { ..._original, ...mockStore.get('/absolute/path/random.js') }
: _original;
return module.random(...args);
};
const _original2 = await import('./math.js');
const add = (...args) => {
const module = mockStore.has('/absolute/path/math.js')
? { ..._original2, ...mockStore.get('/absolute/path/math.js') }
: _original2;
return module.add(...args);
};
const subtract = (...args) => {
const module = mockStore.has('/absolute/path/math.js')
? { ..._original2, ...mockStore.get('/absolute/path/math.js') }
: _original2;
return module.subtract(...args);
};
const _original3 = await import('./utils.js');
const utils = mockStore.has('/absolute/path/utils.js')
? { ..._original3, ...mockStore.get('/absolute/path/utils.js') }
: _original3;
이제 드디어 Jest처럼 import를 최상단에 작성할 수 있다.
// random.js
export const random = () => Math.random();
// game.js
import { random } from './random.js';
export const play = () => random() * 10;
// game.test.js
import { play } from './game.js'; // ✅ 최상단에 작성 가능
test('[mocking] - mocking random function', () => {
mock('/Users/san/Js-Te/random.js', {
random: () => 3
});
expect(play()).toBe(30); // 성공
});
동작 과정
game.js import → Babel 변환random wrapper 함수 생성random 저장mock() 호출 → mockStore에 등록play() 호출play() 내부에서 random() 호출random wrapper 함수 실행3 * 10 = 30 반환여기까지 하면 목표는 모두 달성했다.
하지만 현재 로직의 동작 과정은 전체 로직 내의 import문을 Wrapper 패턴으로 감싼다는 문제가 있다.
// mock하지 않는 모듈도 wrapper로 변환됨
import { someFunction } from './normal-module.js';
// ↓
const someFunction = (...args) => {
// 매번 mockStore 체크 (불필요!)
const module = mockStore.has('/path/normal-module.js')
? { ..._original, ...mockStore.get('/path/normal-module.js') }
: _original;
return module.someFunction(...args);
};
여기서 Jest에서 봤던 mock 호이스팅 기능의 아이디어를 참고했다.
1단계: 테스트 파일 분석
먼저 모든 테스트 파일을 스캔하여
mockedPaths 에 모킹이된 경로를 미리 저장한다.
// collectMocks.js
export const collectMockedPaths = (testFiles) => {
const mockedPaths = new Set();
for (const file of testFiles) {
const code = fs.readFileSync(file, 'utf-8');
// Babel로 파싱
const ast = parse(code, {
sourceType: 'module',
plugins: ['dynamicImport']
});
// mock() 호출 찾기
traverse(ast, {
CallExpression(path) {
if (path.node.callee.name === 'mock') {
const mockPath = path.node.arguments[0].value;
const absolutePath = resolveAbsolutePath(mockPath, file);
mockedPaths.add(absolutePath);
}
}
});
}
return mockedPaths;
};
2단계: CLI에서 플러그인에 전달
// cli.js
const main = async () => {
const testFiles = findTestFiles(process.cwd());
// 1. mock된 경로 수집
const mockedPaths = collectMockedPaths(testFiles);
// 2. 소스 파일 변환 (mockedPaths 전달)
const sourceFiles = findAllSourceFiles(process.cwd());
for (const file of sourceFiles) {
transformFiles(file, mockedPaths); // ✅ mock된 경로만 변환
}
// 3. 테스트 실행...
};
3단계: Babel 플러그인에서 체크
ImportDeclaration(nodePath, state) {
const source = nodePath.node.source.value;
const absolutePath = calculateAbsolutePath(source, state);
// ✅ mock 대상이 아니면 변환하지 않음
if (mockedPaths && !mockedPaths.has(absolutePath)) {
return; // 원본 import 그대로 유지
}
// mock 대상만 wrapper로 변환
// ...
}
결과
// mock되는 모듈
import { random } from './random.js';
// ↓ wrapper로 변환됨
const random = (...args) => { ... };
// mock 안 되는 모듈
import { normal } from './normal.js';
// ↓ 그대로 유지됨
import { normal } from './normal.js';
// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
// math.test.js
const { add, subtract, multiply } = import('./math.js'); // 0.3.0 버전부터는 최상단에 선언 가능
test('부분 모킹 예제', async () => {
mock('/Users/san/untitled/index.js', {
multiply: () => 100
});
expect(add(2, 3)).toBe(5);
expect(subtract(5, 3)).toBe(2);
expect(multiply(2, 3)).toBe(100);
});