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

SanE·2025년 12월 8일
post-thumbnail

🤔 들어가며

3부에서는 npm 배포 과정과 프로젝트를 마무리하며 느낀 점을 다뤘다.

1차 배포(v0.1.2)를 완료한 후, 남겨두었던 개선 과제들을 해결하기 위해 노력했다.

  • CommonJS 지원: ESM만 지원하던 라이브러리를 CommonJS 환경에서도 사용할 수 있도록 개선
  • 동적 import 문제: mock 후 반드시 동적 import를 해야 하는 불편함 해결

현재 이러한 개선점들을 모두 적용한 v0.3.0 버전을 배포한 상태다.

이번 포스트부터는 v0.1.2 이후 진행한 개선 작업들을 단계별로 풀어보고자 한다.

💡 왜 듀얼 패키지가 필요한가?

📋 ESM vs CommonJS

현재 내가 개발하는 것은 라이브러리다.

라이브러리는 다양한 환경의 사용자들이 모두 사용할 수 있는 범용성이 중요하다. 그런데 현재 JavaScript 생태계에는 두 가지 모듈 시스템이 공존하고 있다.

ESM (ES6 Modules)

import { test, expect } from '@dannysir/js-te';

export const myFunction = () => { ... };

CommonJS

const { test, expect } = require('@dannysir/js-te');

module.exports = { myFunction };

문제 상황

현재 js-te는 ESM만 지원하기 때문에

  • "type": "module" 설정이 없는 프로젝트에서 사용 불가
  • CommonJS를 사용하는 프로젝트와 호환 불가

📋 해결 전략

두 가지 방식을 모두 지원하기 위해서는 package.json에서 다음과 같이 설정해야 한다.

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

해당 설정을 보면 어쨌든 2가지 파일이 필요한 것을 알 수 있다.
이제 두 가지 파일을 만드는 방법을 고민해보자.

방법 1: 수동으로 두 파일 관리

src/
  index.esm.js  // ESM 버전
  index.cjs.js  // CommonJS 버전

❌ 유지보수가 어렵고, 코드 중복 발생

방법 2: Rollup 등 번들러 사용

src/
  index.js  // 하나의 소스

✅ 하나의 소스에서 자동으로 두 버전 생성

💡 Rollup 설정하기

📋 번들러 사용 이유

현재 ESM 기반으로 만들어진 프로젝트에서 CommonJS 버전의 파일을 수동으로 추가하는 것은 현실적으로 어렵다.

특히 앞으로 개선할 내용이 많은 상태에서 매 개선마다 두 개의 파일을 수정하는 것은 큰 부담이다. 실수로 한쪽만 수정하면 버그가 발생할 수도 있다.

따라서 Rollup을 이용해 index.js 파일 하나를 두 가지 형태로 빌드하는 방식을 선택했다.

📋 왜 Rollup인가?

우리가 아는 번들러에는 여러 종류가 있다.
예를 들어 Webpack, Parcel, esbuild 등이 있는데, 왜 하필 Rollup을 선택했을까?

선택 이유

Webpack의 경우 다양한 기능을 제공하지만, 생성되는 코드에 Webpack 런타임 코드가 함께 포함되어 결과물이 커지고 복잡해진다.

반면 Rollup은

  • 간단한 설정으로 다양한 포맷을 생성할 수 있음
  • 효율적인 Tree-shaking 제공으로 불필요한 코드 제거
  • 생성된 결과물이 더 작고 간결함
  • 라이브러리 제작에 필요한 기능에 집중
// Rollup 설정 예시
output: [
  { file: 'dist/index.mjs', format: 'esm' },    // ESM 출력
  { file: 'dist/index.cjs', format: 'cjs' }     // CommonJS 출력
]

다시 한번 강조하자면, 우리가 만드는 것은 다른 사용자의 프로젝트에서 import되어 사용되는 라이브러리다.

라이브러리는 애플리케이션과 달리 다른 프로젝트의 일부가 되기 때문에

  • 번들 크기가 최소화되어야 함
  • 코드가 깔끔하고 예측 가능해야 함
  • 불필요한 런타임 코드가 없어야 함

따라서 라이브러리 번들링에 특화된 Rollup이 Webpack보다 적합하다고 판단하여 선택했다.

📋 필요한 패키지 설치

npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs

각 패키지의 역할

  • rollup: 번들러 본체
  • @rollup/plugin-node-resolve: node_modules의 패키지를 찾아줌
  • @rollup/plugin-commonjs: CommonJS 모듈을 ESM으로 변환

📋 Rollup 설정 파일 작성

rollup.config.js 파일을 생성한다.

import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default [
  // ESM 빌드
  {
    input: 'index.js',
    output: {
      file: 'dist/index.mjs',
      format: 'esm',
      sourcemap: true
    },
    external: [
      '@babel/core',
      '@babel/generator',
      '@babel/parser',
      '@babel/traverse',
      'fs',
      'path'
    ],
    plugins: [
      nodeResolve(),
      commonjs()
    ]
  },
  // CommonJS 빌드
  {
    input: 'index.js',
    output: {
      file: 'dist/index.cjs',
      format: 'cjs',
      sourcemap: true,
      exports: 'named'
    },
    external: [
      '@babel/core',
      '@babel/generator',
      '@babel/parser',
      '@babel/traverse',
      'fs',
      'path'
    ],
    plugins: [
      nodeResolve(),
      commonjs()
    ]
  }
];

설정 설명

  1. input: 번들링의 진입점 파일
  2. output
    • file: 결과물이 저장될 경로
    • format: 출력 형태 (esm 또는 cjs)
    • sourcemap: 디버깅을 위한 소스맵 생성
    • exports: CommonJS의 export 방식 설정
  3. external: 번들에 포함하지 않을 패키지들
    • Babel 관련 패키지는 사용자가 직접 설치하도록 함
    • Node.js 내장 모듈(fs, path)은 번들하지 않음
  4. plugins
    • nodeResolve(): npm 패키지 해석
    • commonjs(): CommonJS 모듈을 ESM으로 변환

💡 package.json 설정하기

📋 빌드 스크립트 추가

{
  "scripts": {
    "build": "rollup -c",
    "prepublishOnly": "npm run build"
  }
}
  • build: Rollup 빌드 실행
  • prepublishOnly: npm publish 전에 자동으로 빌드 실행

📋 진입점 설정

이제 사용자의 환경에 따라 적절한 파일을 제공하도록 설정한다.

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./src/mock/store.js": "./src/mock/store.js"
  }
}

📋 배포할 파일 지정

빌드된 파일만 npm에 포함하도록 설정한다.

{
  "files": [
    "bin/",
    "dist/",
    "utils/",
    "src/",
    "babelTransformImport.js",
    "constants.js"
  ]
}

이제 src/ 디렉토리의 원본 소스와 dist/ 디렉토리의 빌드 결과물이 모두 포함된다.

💡 CLI에서 사용자 환경 감지하기

이제 npm run build를 실행하면 테스트가 가능할까?

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

cli.js에서는 index.js의 변수들을 전역으로 등록하여 테스트를 실행하는 로직이 있다.
그러나 우리는 이제 index.js 파일이 없고,
빌드 후에 생성된 index.mjs와 index.cjs 파일만 존재한다.

따라서 사용자의 모듈 시스템에 따라 cli.js에서 적절한 index 파일을 호출해야 한다.

// cli.js
const getUserModuleType = () => {
  try {
    const pkgPath = path.join(process.cwd(), 'package.json');
    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
    return pkg.type === 'module' ? 'esm' : 'cjs';
  } catch {
    return 'cjs'; // 기본값은 CommonJS
  }
};

const main = async () => {
  try {
    const moduleType = getUserModuleType();

    let jsTe;
    if (moduleType === 'esm') {
      // ESM 방식으로 import
      jsTe = await import('@dannysir/js-te');
    } else {
      // CommonJS 방식으로 require
      const { createRequire } = await import('module');
      const require = createRequire(import.meta.url);
      jsTe = require('@dannysir/js-te');
    }

    // 전역에 등록
    Object.keys(jsTe).forEach(key => {
      global[key] = jsTe[key];
    });

    // 테스트 실행...
  } catch (error) {
    console.error('Test execution failed:', error);
    process.exit(1);
  }
};

동작 과정

  1. 사용자 프로젝트의 package.json 읽기
  2. type 필드 확인
    • "module": ESM 프로젝트
    • 없거나 "commonjs": CommonJS 프로젝트
  3. 감지된 환경에 맞게 라이브러리 로드
  4. 전역에 API 등록

💡 Babel 플러그인 수정

이제 Rollup을 이용한 파일 빌드도 완료했고, cli.js도 수정했다.

그럼 드디어 require문을 이용할 수 있을까?

결론부터 말하자면 아직도 불가능하다.

현재 라이브러리의 전체 흐름을 다시 살펴보자.

  1. ✅ cli.js를 진입점으로 실행
  2. ✅ index 파일에 있는 API 전역 등록
  3. ☑️ Babel이 전체 파일의 import/require문 변경
  4. ✅ 테스트 파일 실행

📋 babelTransformImport.js 파일 수정

현재 Babel 플러그인은 import문만 처리하고 있다.

따라서 CommonJS의 require문도 처리하도록 수정해야 한다.

// 원본 require문
const { random } = require('./random.js');

// Babel이 수정한 require문
const _original = require('./random.js');
const module = mockStore.has('/abs/path')
  ? { ..._original, ...mockStore.get('/abs/path') }
  : _original;
const { random } = module;

구현 방법

Babel의 visitor 패턴에 VariableDeclaration 핸들러를 추가한다.

export const babelTransformImport = (mockedPaths = null) => {
  return ({ types: t }) => {
    return {
      visitor: {
        // 기존 ImportDeclaration은 ESM 처리
        ImportDeclaration(nodePath, state) {
          // ... ESM import 변환 로직
        },

        // 새로 추가: CommonJS require 처리
        VariableDeclaration(nodePath, state) {
          // require() 구문 찾기
          // require문을 조건부로 변환
        }
      }
    };
  };
};

💡 전체 Babel 플러그인 코드는 GitHub 레포지토리를 참고

AST를 수정하는 Babel 플러그인 구조에 관한 자세한 설명은 2부 포스트를 참고

💡 부분 모킹 지원

앞선 require문 변환 예시를 보면 이전 import문 변환과 조금 다른 것을 알 수 있다.

// [이전] import문 변환 구조
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 { random1, random2 } = _module;

// [현재] 개선된 구조
const _original = await import('./random.js');
const _module = mockStore.has('/path/to/random.js')
  ? { ..._original, ...mockStore.get('/path/to/random.js') }
  : _original;
const { random1, random2 } = _module;

📋 기존 방식의 문제점

처음에 구현한 구조에 한가지 문제가 있다는 것을 개발을 진행하며 알게 되었다.

문제 상황

// 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
test('부분 모킹 시도', async () => {
  // multiply만 모킹
  mock('/Users/san/math.js', {
    multiply: () => 100
  });

  const { add, subtract, multiply } = await import('./math.js');

  expect(add(2, 3)).toBe(5);        // ❌ add가 undefined!
  expect(subtract(5, 3)).toBe(2);   // ❌ subtract도 undefined!
  expect(multiply(2, 3)).toBe(100); // ✅ 성공
});

문제의 원인

  • mockStore.get()은 우리가 지정한 함수만 반환: { multiply: () => 100 }
  • 원본 모듈은 로드되지 않음
  • 결과적으로 add와 subtract는 undefined

📋 개선된 방식

스프레드 연산자를 사용하여 원본과 mock을 병합한다.

// 변환 결과
const _original = await import('./math.js');
const _module = mockStore.has('/path/math.js')
  ? { ..._original, ...mockStore.get('/path/math.js') }  // ✅ 병합!
  : _original;
const { add, subtract, multiply } = _module;

동작 원리

  1. _original: 모든 원본 함수 포함 { add, subtract, multiply }
  2. mockStore.get(): mock 함수만 포함 { multiply: () => 100 }
  3. 스프레드 연산자로 병합: { add, subtract, multiply: () => 100 }
  4. 나중에 선언된 값이 덮어씀 → multiply만 mock 버전 사용

결과

test('부분 모킹 성공', async () => {
  mock('/absolute/path/math.js', {
    multiply: () => 999  // multiply만 모킹
  });

  const { add, subtract, multiply } = await import('./math.js');

  expect(add(2, 3)).toBe(5);        // ✅ 원본 사용
  expect(subtract(5, 2)).toBe(3);   // ✅ 원본 사용
  expect(multiply(2, 3)).toBe(999); // ✅ mock 사용
});

이제 사용자는

  • 일부 함수만 선택적으로 모킹 가능
  • 나머지 함수는 원본 그대로 사용
  • 더 유연한 테스트 작성 가능

💡 최종 결과

📋 빌드 실행

npm run build

빌드가 완료되면 다음과 같은 구조가 생성된다

dist/
  ├── index.mjs          # ESM 버전
  ├── index.mjs.map
  ├── index.cjs          # CommonJS 버전
  └── index.cjs.map

📋 ESM 프로젝트에서 테스트

// package.json
{
  "type": "module",
  "scripts": {
    "test": "js-te"
  }
}
// test.js
import { test, expect } from '@dannysir/js-te'; //작성하지 않아도 작동하지만 예시를 위해 작성

test('ESM test', () => {
  expect(1 + 1).toBe(2);
});

📋 CommonJS 프로젝트에서 테스트

// package.json
{
  "type": "commonjs",
  "scripts": {
    "test": "js-te"
  }
}
// test.js
const { test, expect } = require('@dannysir/js-te');

test('CommonJS test', () => {
  expect(1 + 1).toBe(2);
});

😅 후기

처음에는 Rollup만 사용하여 index를 빌드하면 Common Js를 지원할 수 있을 거라고 생각했다.
하지만 작업을 진행하며 생각보다 고려할 점이 많다는 것을 알게 되었다.

특히 cli.js에서 index 파일을 호출하야 하는데 사용자에 따라 호출되는 index 파일이 달라서 이 부분을 해결하기 위해 많이 고민했다.

📎 프로젝트 링크

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

0개의 댓글