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

SanE·2025년 11월 21일
post-thumbnail

🤔 라이브러리 제작 동기

우아한테크코스 과제를 진행하며 혼자만의 추가 목표를 세워서 진행했다.

“모든 미션을 TDD로 진행해보자!”

테스트 작성 → 개발 → 리팩토링의 사이클을 가져가며 테스트 코드 작성에는 Jest를 사용했다.

이렇게 테스트 코드를 작성하다 보니 한 가지 궁금증이 생겼다.

"테스트 프레임워크는 어떻게 동작할까?"

평소에도 다양한 npm 라이브러리를 사용하며 나도 나만의 라이브러리를 만들고 싶은 마음이 있었고,
이번 기회에 테스트 라이브러리를 제작해보기로 했다.

이 프로젝트는 Jest에서 영감을 받아 제작했기 때문에 많은 부분을 Jest를 참고하여 제작함.

해당 포스트는 전체 코드보다는 핵심 로직만 설명을 하고 있기 때문에 전체 코드가 궁금한 경우 깃허브를 참고


💡 프로젝트 세팅 - NPM 패키지 만들기

npm init으로 시작하기

가장 먼저 npm 패키지를 생성한다.

npm init을 실행하면 다양한 설정값을 입력하게 되는데, 프로젝트 상황에 맞게 설정하면 된다.

npm init

package.json의 핵심 설정은 다음과 같다.

{
  "name": "@dannysir/js-te",
  "version": "0.0.1",
  "type": "module",
  "main": "index.js",
  "bin": {
    "js-te": "./bin/cli.js"
  }
}
  • type: "module": ESM(ES6 Modules) 방식 사용
  • bin
    • 사용자가 npm test 또는 js-te 명령어를 실행했을 때 호출되는 진입점이다.
    • 이 라이브러리에서는 bin에 지정한 파일에서 테스트 파일을 찾고 실행하는 로직을 구현해야 한다.
  • @dannysir/js-te
    • 초기에는 js-te로 등록했지만, npm에 이미 유사한 이름(jste)이 존재하여 배포가 불가능했다.
    • 이를 해결하기 위해 scoped package로 변경했다. (앞에 @가 붙은 패키지 이름이 scoped package다)

Scoped package는 @사용자명/패키지명 형태로 자신만의 네임스페이스를 가질 수 있게 해주는 방식이다.

📋 Shebang으로 CLI 실행하기

우리가 bin 으로 진입점을 지정해도 어떻게 실행시킬지 표시하기 위해 Shebang이 필요하다.

#!/usr/bin/env node

import fs from 'fs';
// ... CLI 로직

왜 필요할까?

  • Shebang(#!/usr/bin/env node)은 Unix/Linux 시스템에 "이 파일을 node로 실행해라"라고 알려준다.
  • 이제 사용자가 js-te 명령어를 입력하면 bin 파일을 진입점으로 들어올 것이고 이후 Shebang을 통해 파일을 node로 실행할 것이다.

그럼 /usr/bin/env 는 뭘까?

#!/usr/local/bin/node  // ❌ node 위치가 다르면 실패
#!/usr/bin/env node    // ✅ PATH에서 node를 찾음

사용자마다 node 설치 위치가 다를 수 있다.

 env를 사용하면 시스템의 PATH에서 node를 찾아 실행한다.

💡 cli.js - 자동 테스트 파일 찾기

이제 cli.js 파일을 채워보자.

cli.js에서 진행해야할 작업을 정리하면 다음과 같다.

  • 전체 파일을 돌며 테스트를 진행할 파일을 찾는다.
  • 발견한 테스트 파일을 순차적으로 실행.

아래는 디렉토리에서 테스트 파일을 찾는 로직이다.

찾는 규칙

  1. .test.js 파일
  2. test/ 폴더 안의 모든 .js 파일
  3. node_modules는 제외
const findTestFiles = (dir) => {
  const files = [];

  const walk = (directory, inTestDir = false) => {
    const items = fs.readdirSync(directory);
    const isTestDir = path.basename(directory) === 'test' || inTestDir;

    for (const item of items) {
      if (item === 'node_modules') continue;

      const fullPath = path.join(directory, item);
      const stat = fs.statSync(fullPath);

      if (stat.isDirectory()) {
        walk(fullPath, isTestDir);
      } else if (item.endsWith('.test.js') || isTestDir) {
        if (item.endsWith('.js')) {
          files.push(fullPath);
        }
      }
    }
  };

  walk(dir);
  return files;
};

이제 cli.js에서 테스트 파일은 다 찾았다. 그럼 이제 테스트 구조를 설계해보자.

💡 테스트 실행 구조 이해하기

우선 사용자가 등록한 testdescription 가 순차적으로 실행되야 하고
추후에 추가될 기능을 위해 스택 구조로 테스트 훅을 저장했다.

📋 1단계: 테스트 등록

  • describe : 현재 depth를 기록하고 내부 함수를 실행
  • test : 테스트 정보를 배열에 저장
export class Tests {
  #tests = []; // 테스트 훅 저장 스택
  #testDepth = []; // 다중 description 사용시 경로 표시를 위한 스택

  test(description, fn) {
    const testObj = {
      description,
      fn,
      path: this.#testDepth.join(' > ')
    };
    this.#tests.push(testObj);
  }

  describe(suiteName, fn) {
    this.#testDepth.push(suiteName);
    fn(); // 내부 테스트들이 등록됨
    this.#testDepth.pop();
  }
}

📋 2단계: 테스트 실행

  1. 등록된 테스트들을 순회하며 실행 - 결과 - 출력

  2. 테스트 결과 !== 예상 값 => throw new Error

  3. 아래 보이는 try/catch 문을 통해 처리

export const run = async () => {
  let passed = 0;
  let failed = 0;

  for (const test of tests.getTests()) {
    try {
      await test.fn();
      console.log(green('✓ ') + test.path + test.description);
      passed++;
    } catch (error) {
      console.log(red('✗ ') + test.path + test.description);
      console.log(red(`  Error: ${error.message}`));
      failed++;
    }
  }

  return { passed, failed };
};

💡 Matcher 구현하기

📋 expect의 구조

matcher는 테스트의 기댓값을 검증하는 메서드다. 다음과 같은 방식으로 동작한다:

  • toBe===로 원시값 비교
  • toEqualJSON.stringify로 객체/배열의 내용 비교
  • toThrow: 함수 실행 후 에러 발생 여부 확인
  • toBeTruthy / toBeFalsy: 참/거짓 여부 확인

구현 로직

  • expect()는 객체를 반환하고, 각 matcher 메서드는 검증에 실패하면 에러를 던진다.
  • runArgFnc()는 함수가 전달된 경우 실행하여 결과값을 얻는다.
export const expect = (actual) => {
  let value = actual;

  const runArgFnc = (actual) => {
    let value = actual;
    if (typeof actual === 'function') {
      value = actual();
    }
    return value;
  };

  return {
    toBe(expected) {
      value = runArgFnc(actual);
      if (value !== expected) {
        throw new Error(getErrorMsg(expected, value));
      }
    },
    toEqual(expected) {
      value = runArgFnc(actual);
      if (JSON.stringify(value) !== JSON.stringify(expected)) {
        throw new Error(getErrorMsg(expected, value));
      }
    },
    toThrow(expected) {
      try {
        value = runArgFnc(actual);
      } catch (e) {
        if (!e.message.includes(expected)) {
          throw new Error(getErrorMsg(expected, e.message));
        } else return;
      }
    },
    toBeTruthy() {
      value = runArgFnc(actual);
      if (!value) {
        throw new Error(getErrorMsg(true, value));
      }
    },
    toBeFalsy() {
      value = runArgFnc(actual);
      if (value) {
        throw new Error(getErrorMsg(true, value));
      }
    },
  };
};

📋 공개 API 정의하기

이제 사용자가 test()describe()expect()를 사용할 수 있도록 공개 API를 정의한다.

// index.js
import { Tests } from "./src/tests.js";

const tests = new Tests();

export const test = (description, fn) => tests.test(description, fn);
export const describe = (suiteName, fn) => tests.describe(suiteName, fn);
export const expect = (actual) => {
  // ... expect 로직
};

그리고 package.json 파일의 exports 옵션을 설정하여 해당 파일을 패키지의 진입점으로 지정한다.

{
  "exports": {
    ".": "./index.js"
  }
}

이제 사용자는 다음과 같이 라이브러리를 사용할 수 있다

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

💡 Jest처럼 import 없이 사용하기

📋 문제 상황

이제 우리는 다음과 같이 테스트 코드를 작성할 수 있다.

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

describe('Math', () => {
  test('works', () => {
    expect(1).toBe(1);
  });
});

하지만 Jest 코드를 보면 jest는 위에서 보이는 import문이 없어도 사용할 수 있는 것을 알 수 있다.

import문 없이 사용하려면 어떻게 해야할까?

📋 해결 방법 - 전역(global) 등록

이를 해결하기 위해 우리가 진입점으로 설정한 cli.js에서 전역 객체에 미리 함수들을 등록했다.

  1. CLI가 실행될 때 describetestexpect 등이 전역에 등록
  2. 사용자는 import 없이 바로 사용 가능
// cli.js
import * as jsTe from '../index.js';

// 모든 공개 API를 전역에 등록
Object.keys(jsTe).forEach(key => {
  global[key] = jsTe[key];
});

💡 beforeEach 구현하기

이제 간단한 matcher를 통해 테스트를 진행할 수 있다.

이제부터는 추가적으로 필요한 기능들을 하나씩 추가해보자.


테스트 코드를 작성하다 보면 각 테스트 전에 실행해야 하는 전처리 로직이 자주 필요하다.

따라서 다음 단계로 beforeEach를 구현해보자.

구현 로직

  • test 등록 시 현재의 beforeEach 훅들을 저장
  • 중첩된 describe는 상위의 beforeEach를 모두 실행
  • describe 종료 시 배열 길이를 복원하여 중첩 구조 대응
export class Tests {
  #beforeEachArr = [];

  beforeEach(fn) {
    this.#beforeEachArr.push(fn);
  }

  test(description, fn) {
    const beforeEachHooks = [...this.#beforeEachArr];

    const testObj = {
      description,
      fn: async () => {
        // beforeEach 훅들을 먼저 실행
        for (const hook of beforeEachHooks) {
          await hook();
        }
        await fn();
      },
      path: this.#testDepth.join(' > ')
    };
    this.#tests.push(testObj);
  }

  describe(suiteName, fn) {
    this.#testDepth.push(suiteName);
    const prevLength = this.#beforeEachArr.length;
    fn();
    this.#beforeEachArr.length = prevLength; // describe 종료 시 복원
    this.#testDepth.pop();
  }
}

💡 test.each 구현하기

다음으로 자주 사용하는 기능을 생각해보니 each()가 떠올랐다.

그런데 구현을 위해 설계하며 고민이 되는 부분이 두 가지 있었다.

  1. Jest의 경우 each()에 따른 다양한 플레이스홀더가 있다. 이 부분을 어떻게 구현할까?
  2. 현재 test는 테스트 코드를 등록하는 함수인데, 여기에 test.each를 어떻게 추가할 수 있을까?

📋 each 와 플레이스홀더

Jest에는 %i(정수), %d(숫자), %f(부동소수점) 등 다양한 플레이스홀더가 있다.
하지만 이들은 모두 테스트 설명 문자열에 값을 삽입하는 용도이고,
JavaScript에서는 결국 문자열로 변환되기 때문에 %s로 대부분의 케이스를 커버할 수 있다.

따라서 실질적으로 구분이 필요한 %s(문자열/숫자)와 %o(객체)만 우선 구현했다.

  • %s: 문자열/숫자
  • %o: 객체 (JSON.stringify)
export class Tests {

  /* 생략 */

  testEach(cases) {
    return (description, fn) => {
      cases.forEach(testCase => {
        const args = Array.isArray(testCase) ? testCase : [testCase];
        this.test(this.#formatDescription(args, description), () => fn(...args));
      });
    };
  }

  #formatDescription(args, description) {
    let argIndex = 0;
    return description.replace(/%([so])/g, (match, type) => {
      if (argIndex >= args.length) return match;

      const arg = args[argIndex++];

      switch (type) {
        case 's':
          return arg;
        case 'o':
          return JSON.stringify(arg);
        default:
          return match;
      }
    });
  }
}

📋 test.each로 호출하게 만들기

그렇다면 test에서 each 메서드를 어떻게 호출하게 만들 수 있을까?

코드를 보면 생각보다 간단하다.

// index.js
import { Tests } from "./src/tests.js";

const tests = new Tests();

export const test = (description, fn) => tests.test(description, fn);
test.each = (cases) => tests.testEach(cases);

이게 어떻게 가능할까?

그 이유는 JavaScript에서 함수도 객체이기 때문이다.

JavaScript의 함수는 Function 생성자의 인스턴스이며, 내부적으로 Call 메서드를 가진 호출 가능한 객체다.

test.each()의 사용 예시를 살펴보자.

test.each([
  [1, 2, 3, 6],
  [3, 4, 5, 12],
  [10, 20, 13, 43],
  [10, 12, 13, 35],
])('[each test] - input : %s, %s, %s, %s', (a, b, c, result) => {
  expect(a + b + c).toBe(result);
});

/*
✓ [each test] - input : 1, 2, 3, 6
✓ [each test] - input : 3, 4, 5, 12
✓ [each test] - input : 10, 20, 13, 43
✓ [each test] - input : 10, 12, 13, 35
*/

test.each([
  [{ name: 'dannysir', age: null }],
])('[each test placeholder] - input : %o', (arg) => {
  expect(arg.name).toBe('dannysir');
});

/*
✓ [each test placeholder] - input : {"name":"dannysir","age":null}
*/

여기까지 테스트 라이브러리의 기본 구조와 핵심 기능들을 구현했다.

이후에는 모킹(mocking) 기능을 구현하는 과정을 담을 계획인데
생각보다 분량이 길어져서 나눠서 가려고 한다.

📎 프로젝트 링크

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

0개의 댓글