JavaScript 테스트 라이브러리 만들기 (6부)

SanE·2026년 5월 5일
post-thumbnail

🤔 들어가며

5부에서는 Wrapper 패턴을 도입해 import문을 최상단에 작성해도 모킹이 동작하도록 만들었다.

이전 포스트까지가 v0.3.0 버전의 변화였다면, 이번 포스트부터는 v0.4.x 버전의 변경 사항을 다룬다.

v0.3.0에서 사용성을 한 번 크게 개선했지만 모킹이라는 기능 자체에는 여전히 두 가지 불편이 남아 있었다.

  1. mock(path, { foo: () => 1 })에 넘기는 함수가 평범한 화살표 함수라서, 호출 횟수 검증·반환값 시퀀싱·도중 구현 교체 같은 동작이 불가능했다
  2. 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를 제공한다.

📋 두 가지 요구사항으로 분해

해결 방향을 두 갈래로 나눠 정리했다.

  • (A) 사용자가 직접 mock 함수를 만들 수 있어야 한다 → fn() API를 외부로 노출
  • (B) mock(path, {...})에 평범한 함수를 넘겼을 때, 등록 시점에 자동으로 mock 함수로 감싸서 사용자가 별도로 신경 쓰지 않아도 되게 해야 한다

먼저 mock 함수 자체를 구현하고, 그 다음 그것을 모듈 모킹에 자동 연결하는 순서로 진행했다.

💡 함수이면서 메서드도 있는 객체 — makeMockFnc

📋 핵심 아이디어: 함수 위에 메서드 합성

mock 함수가 가져야 할 모습은 다음과 같다.

  • 호출 가능해야 한다 (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;
};

동작 우선순위

  1. returnQueue에 값이 남아있으면 먼저 꺼내서 반환 (mockReturnValueOnce 용)
  2. 없으면 현재 등록된 구현 curImplement를 호출

이렇게 우선순위를 명확히 두면 "한 번만 다른 값을 돌려주고 그 다음부터는 원래 구현으로"라는 패턴이 자연스럽게 표현된다.

📋 체이닝 메서드 — mockFunctions

state를 직접 만지는 메서드들은 별도 파일로 빼서 관리했다.

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 thisreturn 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'     — 원래 구현으로 폴백

💡 모듈 모킹 자동 변환 — changeModuleExports

📋 문제 상황

fn()을 외부에 노출했으니 사용자가 모듈 모킹 시 직접 mock 함수를 넣어줄 수도 있다.

mock('/abs/path/math.js', {
  add: fn(() => 100)
});

하지만 이렇게 매번 사용자가 fn()으로 감싸야 한다면

  • 모듈에 함수가 10개 있으면 10번 감싸야 함
  • 일부만 감싸면 어떤 키는 mock 함수, 어떤 키는 평범한 함수로 섞여 일관성 무너짐
  • 결국 5부에서 우리가 풀어준 사용성 개선이 다른 형태로 다시 사용자에게 떠넘겨지는 셈

📋 해결 — 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.jsmock()이 이 변환을 거쳐 등록하도록 수정한다.

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);
}

중요한 변화 두 가지

  1. mockStore.set 전에 changeModuleExports로 한 번 감싼다
  2. 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부까지 남아있던 불편

5부 마지막 예시를 다시 가져와보자.

test('[mocking] - mocking random function', () => {
  mock('/Users/san/Js-Te/random.js', {  // ← 사용자 머신의 절대 경로
    random: () => 3
  });

  expect(play()).toBe(30);
});

이게 왜 이렇게 됐는지는 2부에서 다뤘다. import 측은 Babel이 변환 시점에 절대 경로로 바꾸는데, mock()에 넘기는 경로는 사용자가 직접 절대 경로를 적도록 정해뒀기 때문이다.

당시에는 빠르게 동작하게 만드는 것이 우선이라 이 결정으로 넘어갔지만, 이건 명백한 짐이다.

  • 머신마다 경로가 다르니 다른 사람과 코드를 공유할 수 없다
  • CI에서 경로가 다르면 그대로 깨진다
  • 프로젝트 디렉토리만 옮겨도 모든 mock 호출이 무용지물이 된다

📋 어디서 변환하는 게 맞을까

해결 위치로 두 가지 후보가 있었다.

선택지 A — 런타임 mock() 함수 안에서 변환

export const mock = (modulePath, mockExports) => {
  const absolutePath = toAbsolute(modulePath);  // 여기서 변환?
  // ...
};

❌ 문제: mock()이 호출되는 위치(어떤 테스트 파일에서 호출됐는지)를 런타임에 알기 어렵다. Error().stack을 파싱하는 우회는 가능하지만 안정성이 떨어지고 매 호출마다 비용이 든다.

선택지 B — Babel 변환 단계에서 mock(...) 호출의 첫 인자를 절대 경로 리터럴로 바꿔치기

✅ Babel은 변환 중인 파일의 경로(state.filename)를 알고 있고, 이미 같은 단계에서 import 경로를 절대 경로로 바꾸고 있다. 같은 자리에서 mock 호출도 함께 처리하는 게 자연스럽다.

당연히 B를 채택했다.

📋 구현 — babelTransformCallExpression 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);
  },
}

동작 흐름

  1. CallExpression 노드 중 calleemock 식별자인 것만 통과
  2. 첫 번째 인자가 string literal인지 확인 (변수로 넘긴 경우는 정적으로 알 수 없으므로 skip)
  3. 현재 변환 중인 파일 경로(state.filename)를 기준으로 절대 경로 계산
  4. 그 자리에 새 string literal을 박아 넣어 교체

여기서 사용한 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 vs v0.4.1 비교

같은 테스트 시나리오를 두 버전으로 나란히 놓아 보자.

// ====== 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()이 돌려준 객체로 시퀀스 제어, 호출 추적 같은 추가 동작이 가능해졌다
  • import도 5부에서 가능해진 대로 최상단에 그대로 둘 수 있다

😅 후기

v0.4.x 작업을 마치고 보니, 라이브러리 사용성을 개선한다는 건 결국 "사용자가 라이브러리 내부 구현을 몰라도 자연스럽게 쓸 수 있게 만드는 일" 이라는 점이 크게 와닿았다.

  • 절대 경로 변환은 라이브러리가 한 번 잘 짜놓으면 사용자는 영원히 신경 쓸 일이 없다
  • mock 함수 자동 래핑도 마찬가지로 사용자에게는 "그냥 함수를 넣었더니 그냥 잘 되더라"가 된다

여기까지 v0.4.x의 변화를 정리했지만, 사실 이 시점에도 라이브러리에는 한 가지 큰 문제가 여전히 남아 있었다.

우리는 아직도 사용자의 소스 파일을 디스크 위에서 직접 덮어쓰고 있었다.

테스트가 끝나면 원본을 복구하긴 하지만, 만약 그 사이에 프로세스가 비정상 종료되면? 사용자의 코드가 변환된 상태로 남는다. 읽기 전용으로 보호된 파일이 있다면 테스트 자체가 실패한다.

이 문제를 어떻게 풀었는지는 다음 포스트에서 다룰 예정이다.

📎 프로젝트 링크

profile
JavaScript를 사용하는 모두를 위해

0개의 댓글