
5부에서는 Wrapper 패턴을 도입해 import문을 최상단에 작성해도 모킹이 동작하도록 만들었다.
이전 포스트까지가 v0.3.0 버전의 변화였다면, 이번 포스트부터는 v0.4.x 버전의 변경 사항을 다룬다.
v0.3.0에서 사용성을 한 번 크게 개선했지만 모킹이라는 기능 자체에는 여전히 두 가지 불편이 남아 있었다.
mock(path, { foo: () => 1 })에 넘기는 함수가 평범한 화살표 함수라서, 호출 횟수 검증·반환값 시퀀싱·도중 구현 교체 같은 동작이 불가능했다mock()의 첫 번째 인자에 사용자가 자신의 머신 절대 경로를 직접 적어야 했다 (5부 마지막 예시: mock('/Users/san/Js-Te/random.js', ...))이번 포스트에서는 v0.4.0이 (1)을, v0.4.1이 (2)를 어떻게 풀어냈는지 다룰 예정이다.
지금까지의 mock()은 사용자가 넘긴 객체를 그대로 mockStore에 저장했다.
mock('/abs/path/random.js', {
random: () => 3
});
여기서 random은 그냥 화살표 함수다. 이 정도면 "항상 3을 반환해라" 같은 단순 케이스는 충분하다.
하지만 테스트를 작성하다 보면 그것보다 한 단계 복잡한 시나리오가 자주 등장한다.
'A', 두 번째 호출에는 'B'를 반환하게 만들고 싶다"이런 걸 평범한 함수로 짜려면 사용자가 직접 클로저 변수를 만들고, 카운터를 두고, 큐를 굴려야 한다.
let calls = 0;
let queue = ['A', 'B'];
mock('/abs/path/api.js', {
fetch: () => {
calls++;
return queue.shift();
}
});
// 테스트가 끝나면 calls, queue를 직접 리셋해야 함
이런 게 한 두 군데가 아니라 모든 테스트에서 반복된다면, 라이브러리가 같이 들어줘야 할 일이 사용자 손에 그대로 떠넘겨지고 있다는 뜻이다.
Jest는 이런 이유로
jest.fn()이라는 별도 API를 제공한다.
해결 방향을 두 갈래로 나눠 정리했다.
fn() API를 외부로 노출mock(path, {...})에 평범한 함수를 넘겼을 때, 등록 시점에 자동으로 mock 함수로 감싸서 사용자가 별도로 신경 쓰지 않아도 되게 해야 한다먼저 mock 함수 자체를 구현하고, 그 다음 그것을 모듈 모킹에 자동 연결하는 순서로 진행했다.
makeMockFncmock 함수가 가져야 할 모습은 다음과 같다.
mockFn(args))mockFn.mockReturnValue(...), mockFn.mockClear() …)1부에서 test.each를 다룰 때 "JavaScript의 함수도 객체다" 라는 점을 활용했었는데, 이 성질이 여기서 한 번 더 살아난다.
함수 본체는 클로저로 가둔 state를 보고 동작하게 만들고, 그 함수 위에 Object.assign으로 메서드들을 끼워 넣으면 된다.
const makeMockFnc = (implementation) => {
const state = {
returnQueue: [],
curImplement: implementation || (() => null)
};
const mockFn = (...args) => {
if (state.returnQueue.length > 0) {
return state.returnQueue.shift();
}
return state.curImplement(...args);
};
const methods = mockFunctions(state);
Object.assign(mockFn, methods);
return mockFn;
};
동작 우선순위
returnQueue에 값이 남아있으면 먼저 꺼내서 반환 (mockReturnValueOnce 용)curImplement를 호출이렇게 우선순위를 명확히 두면 "한 번만 다른 값을 돌려주고 그 다음부터는 원래 구현으로"라는 패턴이 자연스럽게 표현된다.
mockFunctionsstate를 직접 만지는 메서드들은 별도 파일로 빼서 관리했다.
export const mockFunctions = (state) => {
return {
mockImplementation(newImpl) {
state.curImplement = newImpl;
return this;
},
mockReturnValueOnce(value) {
state.returnQueue.push(value);
return this;
},
mockReturnValue(value) {
state.curImplement = () => value;
return this;
},
mockClear() {
state.returnQueue = [];
state.curImplement = () => null;
return this;
},
}
};
각 메서드의 의도
mockImplementation(fn): 호출 시 실행될 구현 자체를 통째로 바꾼다mockReturnValue(v): "이제부터 무조건 v를 돌려줘"mockReturnValueOnce(v): "딱 다음 한 번만 v를 돌려주고, 그 후엔 원래대로"mockClear(): 큐와 구현을 초기 상태로 되돌린다return this → return mockFn처음에는 위 코드처럼 모든 메서드가 return this를 하고 있었다.
체이닝(mockFn.mockReturnValue(1).mockClear())을 위한 흔한 패턴이지만 사용하다 보니 한 가지 함정이 있다는 걸 알게 됐다.
const fetchMock = fn();
const sameRef = fetchMock.mockReturnValue(1);
// sameRef === fetchMock 일까?
this는 호출 시점의 컨텍스트에 따라 결정된다. Object.assign으로 합성된 함수에서 메서드를 호출했을 때 this가 정확히 어디를 가리키는지는 사용자 입장에서 직관적이지 않다.
라이브러리 API에서는 사용자가 어떻게 호출하든 항상 동일한 mockFn 객체가 돌아오는 게 안전하다. 그래서 state 인자에 더해 mockFn도 같이 넘기고, 모든 메서드가 명시적으로 mockFn을 반환하도록 바꿨다.
export const mockFunctions = (mockFn, state) => {
return {
mockImplementation(newImpl) {
state.curImplement = newImpl;
return mockFn;
},
mockReturnValueOnce(...value) {
state.returnQueue.push(...value);
return mockFn;
},
mockReturnValue(value) {
state.curImplement = () => value;
return mockFn;
},
mockClear() {
state.returnQueue = [];
state.curImplement = () => null;
return mockFn;
},
}
};
작은 변경이지만, 라이브러리 API에서는 "어떻게 호출되든 같은 객체가 돌아온다"는 보장을 사용자에게 줄 수 있다는 점에서 의미가 있다.
fn으로 노출이제 만들어둔 makeMockFnc를 패키지의 공개 API로 꺼낸다.
// index.js
import { makeMockFnc } from "./src/mock/utils/changeModuleExports.js";
// ... 기존 export 들
export const fn = makeMockFnc;
사용자는 이렇게 사용할 수 있다.
const callApi = fn(() => 'ok');
callApi.mockReturnValueOnce('first');
callApi(); // 'first' — 큐에서 꺼냄
callApi(); // 'ok' — 원래 구현으로 폴백
changeModuleExportsfn()을 외부에 노출했으니 사용자가 모듈 모킹 시 직접 mock 함수를 넣어줄 수도 있다.
mock('/abs/path/math.js', {
add: fn(() => 100)
});
하지만 이렇게 매번 사용자가 fn()으로 감싸야 한다면
mock() 시점에 객체 키를 순회하며 자동 래핑mock()이 호출되는 그 시점에 객체의 모든 함수를 mock 함수로 감싸서 저장하면 된다. 사용자는 평범한 함수를 넘기더라도 등록 후엔 모두 mock 함수가 된다.
import { mockFunctions } from "./mockFunctions.js";
const makeMockFnc = (implementation) => {
// ... 위에서 본 구현
};
export const changeModuleExports = (moduleExports) => {
const result = {};
for (const moduleName in moduleExports) {
result[moduleName] = makeMockFnc(moduleExports[moduleName]);
}
return result;
};
그리고 store.js의 mock()이 이 변환을 거쳐 등록하도록 수정한다.
import { changeModuleExports } from "./utils/changeModuleExports.js";
export const mockStore = new Map();
export const mock = (modulePath, mockExports) => {
const mockedExports = changeModuleExports(mockExports);
mockStore.set(modulePath, mockedExports);
return mockStore.get(modulePath);
}
중요한 변화 두 가지
mockStore.set 전에 changeModuleExports로 한 번 감싼다mock() 자체가 변환된 객체를 return 한다(2)는 사용자가 등록 직후 바로 mock된 함수에 접근해서 추가 설정을 할 수 있게 해준다.
const mockedMath = mock('/abs/path/math.js', {
add: () => 1
});
mockedMath.add.mockReturnValueOnce(99); // 가능!
호출별로 다른 값을 돌려야 하는 테스트가 한결 깔끔해진다.
test('회원가입 - 첫 시도 실패 후 재시도 성공', () => {
const mockedApi = mock('/abs/path/api.js', {
signup: () => ({ ok: true })
});
mockedApi.signup.mockReturnValueOnce({ ok: false, reason: 'duplicate' });
expect(signupFlow('user@a.com')).toBe('재시도 안내');
expect(signupFlow('user@a.com')).toBe('가입 완료');
});
mockClear로 상태를 깨끗이 비우고 다음 단계를 시작하는 것도 자연스럽다.
test('상태 리셋 후 새 시나리오', () => {
const mockedApi = mock('/abs/path/api.js', { fetch: () => 'A' });
mockedApi.fetch.mockReturnValueOnce('once');
expect(mockedApi.fetch()).toBe('once');
mockedApi.fetch.mockClear();
expect(mockedApi.fetch()).toBe(null); // 초기 상태로 복귀
});
여기까지가 v0.4.0의 핵심 변화다. 이제 다음 문제로 넘어가자.
5부 마지막 예시를 다시 가져와보자.
test('[mocking] - mocking random function', () => {
mock('/Users/san/Js-Te/random.js', { // ← 사용자 머신의 절대 경로
random: () => 3
});
expect(play()).toBe(30);
});
이게 왜 이렇게 됐는지는 2부에서 다뤘다. import 측은 Babel이 변환 시점에 절대 경로로 바꾸는데, mock()에 넘기는 경로는 사용자가 직접 절대 경로를 적도록 정해뒀기 때문이다.
당시에는 빠르게 동작하게 만드는 것이 우선이라 이 결정으로 넘어갔지만, 이건 명백한 짐이다.
해결 위치로 두 가지 후보가 있었다.
선택지 A — 런타임 mock() 함수 안에서 변환
export const mock = (modulePath, mockExports) => {
const absolutePath = toAbsolute(modulePath); // 여기서 변환?
// ...
};
❌ 문제: mock()이 호출되는 위치(어떤 테스트 파일에서 호출됐는지)를 런타임에 알기 어렵다. Error().stack을 파싱하는 우회는 가능하지만 안정성이 떨어지고 매 호출마다 비용이 든다.
선택지 B — Babel 변환 단계에서 mock(...) 호출의 첫 인자를 절대 경로 리터럴로 바꿔치기
✅ Babel은 변환 중인 파일의 경로(state.filename)를 알고 있고, 이미 같은 단계에서 import 경로를 절대 경로로 바꾸고 있다. 같은 자리에서 mock 호출도 함께 처리하는 게 자연스럽다.
당연히 B를 채택했다.
babelTransform에 CallExpression visitor 추가기존 플러그인은 ImportDeclaration, VariableDeclaration을 처리하고 있었다. 여기에 CallExpression visitor를 추가해 mock(...) 호출을 잡아내면 된다.
visitor: {
ImportDeclaration(nodePath, state) {
// ESM import 변환
},
VariableDeclaration(nodePath, state) {
// CJS require 변환
},
CallExpression(nodePath, state) {
// mock 호출만 처리
if (!t.isIdentifier(nodePath.node.callee, { name: 'mock' })) {
return;
}
const args = nodePath.node.arguments;
if (args.length < 1 || !t.isStringLiteral(args[0])) {
return;
}
const mockPath = args[0].value;
const currentFilePath = state.filename || process.cwd();
const absolutePath = findAbsolutePath(mockPath, currentFilePath);
nodePath.node.arguments[0] = t.stringLiteral(absolutePath);
},
}
동작 흐름
CallExpression 노드 중 callee가 mock 식별자인 것만 통과state.filename)를 기준으로 절대 경로 계산여기서 사용한 findAbsolutePath는 import 변환 단계에서 이미 쓰던 유틸이다. 새로 만들지 않고 그대로 재사용해서 경로 변환 로직이 한 곳에 모이도록 했다.
플러그인 이름이 지금까지 babelTransformImport 였다. 처음엔 import 구문만 변환했으니 적절한 이름이었지만, 이제는 mock() 호출까지 변환한다.
이름이 의도와 어긋나면 결국 다음에 코드를 읽는 사람을 혼란스럽게 만들기 때문에 함께 정리했다.
babelTransformImport.js → babelTransform.js
babelTransformImport() → babelTransform()
이제 사용자가 작성한 상대 경로 mock 호출이
// 사용자 코드
mock('./random.js', { random: () => 3 });
Babel을 거치면 다음과 같이 절대 경로로 바뀐 채 실행된다.
// 변환 후
mock('/Users/san/project/test/random.js', { random: () => 3 });
import 측에서 만들어진 wrapper도 같은 절대 경로로 mockStore를 조회하기 때문에, 이제 두 경로가 자연스럽게 매칭된다.
같은 테스트 시나리오를 두 버전으로 나란히 놓아 보자.
// ====== v0.3.x ======
// 절대 경로를 직접 적어야 하고, mock 함수가 평범한 함수
test('이전 방식', async () => {
mock('/Users/san/Js-Te/random.js', {
random: () => 3
});
const { play } = await import('./game.js');
expect(play()).toBe(30);
});
// ====== v0.4.1 ======
// 상대 경로 그대로, mock된 함수가 mock function
import { play } from './game.js';
test('현재 방식', () => {
const mocked = mock('./random.js', {
random: () => 3
});
expect(play()).toBe(30);
mocked.random.mockReturnValueOnce(7);
expect(play()).toBe(70);
});
차이는 다음과 같다.
mock()이 돌려준 객체로 시퀀스 제어, 호출 추적 같은 추가 동작이 가능해졌다v0.4.x 작업을 마치고 보니, 라이브러리 사용성을 개선한다는 건 결국 "사용자가 라이브러리 내부 구현을 몰라도 자연스럽게 쓸 수 있게 만드는 일" 이라는 점이 크게 와닿았다.
여기까지 v0.4.x의 변화를 정리했지만, 사실 이 시점에도 라이브러리에는 한 가지 큰 문제가 여전히 남아 있었다.
우리는 아직도 사용자의 소스 파일을 디스크 위에서 직접 덮어쓰고 있었다.
테스트가 끝나면 원본을 복구하긴 하지만, 만약 그 사이에 프로세스가 비정상 종료되면? 사용자의 코드가 변환된 상태로 남는다. 읽기 전용으로 보호된 파일이 있다면 테스트 자체가 실패한다.
이 문제를 어떻게 풀었는지는 다음 포스트에서 다룰 예정이다.