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

SanE·2025년 11월 23일
post-thumbnail

🤔 들어가며

1부에서는 테스트 프레임워크의 기본 구조를 만들었다.

2부에서는 가장 고민이 많았던 모킹(Mocking) 시스템 구현에 대해 다룰 예정이다.

모킹은 외부 의존성을 가짜 구현으로 대체하여 테스트를 독립적으로 만드는 핵심 기능이다. JavaScript의 ESM(ES6 Modules)은 정적으로 분석되고 캐싱되기 때문에 런타임에 모듈을 변경하는 것이 불가능하다.

따라서 Babel을 이용해 mock이 등록된 모듈은 원본 대신 mock 객체를 가져오도록 import 구문을 변환하는 방식으로 구현했다.

💡 모킹? & 구현 설계

📋 모킹(Mocking)?

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을 모킹하여 우리가 예측 할 수 있는 값을 리턴하게 만들어
테스트가 가능하게 만들 수 있다.

📋 어떻게 구현할까?

  1. Wrapper 같은걸 구현?
    • Wrapper를 구현해도 사용자가 정적 import를 하면 컴파일 순간에 바로 가져오기 때문에 수정이 불가능
  2. import를 직접 제어?
    • Node.js Loader Hooks라는 실험적인 기능이 있는것을 발견했지만 구현의 난이도가 높음
import { random } from './random.js';

그럼 Jest는?

Jest는 ESM과 CommonJS 방식을 모두 지원하지만, 내부적으로는 CommonJS 방식을 사용하는 것으로 보인다.

그럼 뭐가 다른 걸까?

require()는 런타임에 실행되며, Jest는 이 시점에 모듈을 가로채서 mock으로 교체한다.

반면 정적 import는 코드 실행 전에 모듈이 미리 로드되기 때문에 런타임에 가로채는 것이 불가능하다.

📋 해결 전략: Babel로 코드 변환하기

핵심 아이디어는 간단하다.

  1. Babel을 통해 모든 파일의 import 구문을 변환한다
  2. 해당 경로가 mock에 등록되어 있으면 mock 객체를 가져온다
  3. mock에 등록되어 있지 않으면 원본 모듈을 가져온다
// 변환 전
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 Store 설계

앞선 해결 전략에 따라 mock을 구현하려면 우선 mock들을 저장할 Store가 필요하다.

구현 로직

  • mockStore: Map 자료구조로 모듈 경로를 key, mock 객체를 value로 저장
  • 그 외 함수들: Map 객체를 활용해 CRUD 기능 구현
// 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 플러그인 구현하기

이제 Babel을 이용해서 import문을 찾아 변환해야 한다.

기본 구조

  • babelTransformImport
    • 직접 구현할 플러그인
    • import문을 조건부 동적 import로 변환
  • dynamicImport
    • 변환 결과에 포함될 await 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문을 만나면 실행

이제 이 내용을 바탕으로 하나씩 구현해보자.

📋 1단계: mockStore 자동 import

모든 파일 최상단에 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';

📋 2단계: Import 구문 변환 전체 설계

우선 하나씩 설계해보자.

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

📋 3단계: 조건부 동적 import 생성

이제 하나씩 구현해보자.

전체 코드를 한 번에 보면 복잡해 보이니 단계별로 살펴보자.

구현 단계

  1. module이라는 이름의 고유 변수 생성
  2. 3항 연산자 생성
    • 조건: 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');

📋 4단계: 구조 분해 할당 처리

이제 import한 모듈에서 필요한 항목들을 추출해야 한다.

Import 방식에는 세 가지 타입이 있다:

  1. Named Import: import { random } from './random.js'
  2. Default Import: import random from './random.js'
  3. Namespace Import: 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;

📋 5단계: 변환한 import문으로 교체

마지막으로 원본 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문이 실행될 때마다

  1. mockStore에 해당 경로가 등록되어 있는지 확인
  2. 등록되어 있으면 mock 객체 사용
  3. 없으면 원본 모듈 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);
});

동작 과정

  1. mock('./random.js', { random: () => 3 })로 mock 등록
  2. game.js를 import하면 Babel이 코드를 변환
  3. game.js 내부의 import { random }이 실행될 때 mockStore 확인
  4. './random.js'가 등록되어 있으므로 random: () => 3 사용
  5. play()3 * 10 = 30 반환

📋 상대 경로의 함정

그런데 여기서 한 가지 문제가 생긴다.

사용자가 상대 경로를 이용할 경우

  • 모듈이 사용되는 위치에 따라 import 경로가 달라짐
  • Map 객체에 저장된 경로는 1개
  • 경로가 달라지면 Map에서 찾지 못하는 문제 발생

예시로 살펴보자

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된다.

📋 절대 경로 변환으로 해결하기

이 문제를 해결하기 위해 모든 경로를 절대 경로로 통일하기로 했다.

해결 전략

  1. 사용자의 import문 확인
  2. 현재 파일의 디렉토리 위치를 기반으로 절대 경로 계산
  3. 절대 경로를 통해 Map에서 찾기

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에서 변환 통합하기

이제 플러그인 개발을 완료했다.

그럼 CLI 부분 로직을 수정해야 한다.

📋 파일 변환 및 복구

전체 흐름

  1. 전체 파일 탐색
  2. import문 변환
  3. 테스트 실행
  4. 원본 코드 복구 (매우 중요!)
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);
}

왜 원본 복구가 중요할까?

  • 사용자의 코드를 Babel로 변환하여 덮어씌웠기 때문
  • 테스트가 끝나면 반드시 원본으로 되돌려야 함
  • 그렇지 않으면 사용자의 코드가 변환된 상태로 남음

📋 최종 사용 예시

이제 테스트 코드에서 절대 경로로 등록하면, 사용자의 코드에서 어떻게 사용해도 모킹이 정상적으로 동작한다.

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

주의 사항

  1. mock은 반드시 절대 경로로 등록
    • 상대 경로 사용 시 매칭 실패
  2. import는 mock 이후에 실행
    • mock 전에 import하면 원본 모듈이 로드됨
  3. 동적 import(await import()) 사용 필요
    • 최종적으로 실행할 모듈의 경우 반드시 동적 import를 해야 함
    • 정적 import는 파일 실행 시점에 즉시 로드됨
    • 동적 import로 mock 등록 후 로드해야 함

📎 프로젝트 링크

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

0개의 댓글