Jest 스타일의 JavaScript 테스트 라이브러리 만들기 (8부)

SanE·2026년 5월 5일
post-thumbnail

🤔 들어가며

7부에서는 module.registerHooks() 로더 훅을 도입해 사용자의 원본 파일을 0바이트도 건드리지 않고 메모리 위에서만 변환하도록 바꿨다.

이전 포스트까지가 v0.5.0 의 변화였다면, 이번 포스트부터는 v0.5.x ~ v0.6.0 의 변경 사항을 다룬다.

7부 마지막에 이런 예고를 남겼다.

이제 시선을 다른 곳으로 돌릴 차례다. 테스트 라이브러리답게 매처(matcher) 자체의 품질을 손볼 차례다.
다음 포스트에서는 JSON.stringify 비교의 한계, .not 체이닝 설계, toThrow 인자 다형화 같은 변화를 다룰 예정이다.

이번 포스트에서는 그 약속대로, 매처 모듈의 구조부터 표현력, 안전성까지 한꺼번에 쌓아올린 작업을 다룰 예정이다.

💡 매처가 빈약했던 자리

7부 시점의 src/matchers.jssrc/expect.js 는 동작은 잘 했지만, 막상 본격적으로 테스트를 짜다 보면 사용자(=내가) 곳곳에서 막혔다.

📋 한계 1 — JSON.stringify 기반 비교

기존 toEqual 은 이렇게 생겼었다.

export const toEqual = (actual, expected) => {
  const value = runArgFnc(actual);
  if (JSON.stringify(value) !== JSON.stringify(expected)) {
    throw new Error(formatErrorMsg(expected, value));
  }
};

직관적이긴 한데 두 가지 함정이 있다.

  • 같은 데이터인데 키 순서가 다르면 비교 결과가 달라진다 — {a:1, b:2}{b:2, a:1}toEqual 입장에선 다른 객체
  • 객체에 순환 참조가 있으면 JSON.stringify 자체가 즉시 TypeError 로 죽는다 — 사용자의 테스트 코드는 멀쩡한데 매처가 먼저 터진다

📋 한계 2 — .not 체이닝 부재

부정 케이스를 짤 방법이 없었다.

// 우리는 이게 안 됐다
expect(value).not.toBe(0);
expect(fn).not.toThrow();

부정을 표현하려면 사용자가 직접 try/catch 로 감싸거나, if로 분기해서 강제로 실패시키는 식의 우회를 써야 했다. 테스트 코드가 비대해진다.

📋 한계 3 — toThrow 가 string includes 만 지원

이전 toThrow 의 핵심 라인은 단 한 줄이었다.

if (!e.message.includes(expected)) { ... }

문자열 비교 한 가지만 가능했다. RegExp, 특정 Error 서브클래스, 사용자 정의 predicate 같은 다른 검증 방식은 모두 사용자가 직접 try/catch 로 풀어야 했다.

📋 한계 4 — mock 호출 추적 자체가 없음

6부에서 fn() mock 함수를 도입했지만, 호출 횟수나 호출 인자를 외부에서 들여다볼 방법이 없었다. "이 함수가 두 번 불렸는가?" 같은 단순한 질문조차 매처로는 검증할 수 없었다.

📋 한계 5 — 매처를 추가할 때마다 따라오는 보일러플레이트

expect.js 가 다음과 같은 모양이었다.

export const expect = (actual) => {
  return {
    toBe(expected) { toBe(actual, expected); },
    toEqual(expected) { toEqual(actual, expected); },
    toThrow(expected) { toThrow(actual, expected); },
    toBeTruthy() { toBeTruthy(actual); },
    toBeFalsy() { toBeFalsy(actual); }
  }
};

매처를 하나 추가하려면 3 곳(매처 본체, expect() 객체 리터럴, 그리고 .not 을 만든다면 또 한 번)을 같이 손대야 한다.

이 다섯 가지 한계가 한 곳을 가리키고 있었다 — 매처의 시그니처 자체를 다시 설계할 때다.

💡 매처 시그니처 통일과 .not 체이닝

가장 먼저 손댄 건 매처 한 개의 모양이다. 모든 매처가 {pass, message} 형태의 객체를 돌려주도록 통일했다.

📋 변경 전 / 변경 후

변경 전 (src/matchers.js)

export const toBe = (actual, expected) => {
  const value = runArgFnc(actual);
  if (value !== expected) {
    throw new Error(formatErrorMsg(expected, value));
  }
};

변경 후 (src/expect/matchers/toBe.js)

export const toBe = (actual, expected) => {
  const value = runArgFnc(actual);
  return {
    pass: Object.is(value, expected),
    message: () => formatErrorMsg(expected, value),
  };
};

매처는 더 이상 직접 throw 하지 않는다. "비교 결과가 통과인지(pass)"와 "실패 시 보여줄 메시지(message 콜백)"만 돌려주고, 실제로 던지는 건 단 한 군데 — expect() 의 공통 로직 — 가 책임진다.

📋 buildExpectApipass === isNot 트릭

src/expect/index.js 의 핵심은 이 한 함수다.

const buildExpectApi = (matcherMap, actual, isNot) => Object.fromEntries(
  Object.entries(matcherMap).map(([name, matcher]) => [name, (...args) => {
    const {pass, message} = matcher(actual, ...args);
    if (pass === isNot) {
      throw new Error(message());
    }
  }])
);

export const expect = (actual) => {
  const api = buildExpectApi(matchers, actual, false);
  api.not = buildExpectApi(matchers, actual, true);
  return api;
};

핵심은 마지막에서 두 번째 줄이다.

if (pass === isNot) throw new Error(message());
  • expect(x).toBe(y)isNot = false 로 빌드된다 → 매처가 pass: false 를 돌려주면 throw
  • expect(x).not.toBe(y)isNot = true 로 빌드된다 → 매처가 pass: true 를 돌려주면 throw

같은 매처 맵 하나에서 정상 표면(api)과 부정 표면(api.not)을 자동으로 생성한다. 매처 본체에는 부정에 대한 코드가 단 한 줄도 없다.

📋 결과 — 매처 하나 추가하면 .not 까지 공짜

매처 시그니처가 통일되자, 새 매처를 추가하는 비용이 극단적으로 줄었다.

// 1. 새 매처 파일 만들기
export const toContain = (actual, expected) => ({
  pass: actual.includes(expected),
  message: () => `...`,
});

// 2. matchers/index.js 에 등록
export const matchers = {
  ...,
  toContain,
};

이게 끝이다. expect(arr).toContain(x) 도, expect(arr).not.toContain(x) 도 그 즉시 동작한다. .not 을 위해 따로 짜야 할 코드가 0줄이다.

이전엔 매처 하나에 3 곳을 손대야 했다. 이제는 1 곳이면 끝이다. 이후 절반 이상의 작업이 사실상 이 구조 위에서 가능해졌다.

💡 toThrow 인자 다형화

매처 시그니처가 정리되고 나니 toThrow 의 빈약함이 더 도드라졌다. 그래서 expected 인자를 다섯 가지 형태로 받도록 확장했다.

📋 분기 표

expected 형태매칭 의미
undefined어떤 에러든 throw 하면 통과 — expect(fn).toThrow()
stringerror.message.includes(expected) (기존 동작 유지)
RegExpexpected.test(error.message)
Error 서브클래스thrown instanceof expected
function (predicate)expected(error) === true

다섯 가지 표현은 사실상 사용자가 검증하고 싶은 거의 모든 형태를 덮는다.

📋 분기 본체

const isErrorClass = (value) => (
  typeof value === 'function' && (value === Error || value.prototype instanceof Error)
);

const matchesExpected = (thrown, expected) => {
  if (expected === undefined) return true;
  if (typeof expected === 'string') {
    return String(thrown?.message ?? thrown).includes(expected);
  }
  if (expected instanceof RegExp) {
    return expected.test(String(thrown?.message ?? thrown));
  }
  if (isErrorClass(expected)) {
    return thrown instanceof expected;
  }
  if (typeof expected === 'function') {
    return expected(thrown) === true;
  }
  return false;
};

여기서 두 가지 디자인 결정이 미묘하다.

(1) Error 분기를 predicate 분기보다 먼저 두기

MyError 같은 사용자 정의 클래스도 결국 JS 함수다 (typeof === 'function'). 만약 predicate 분기가 먼저 오면, expect(fn).toThrow(MyError)MyError(thrown) 을 호출하는 황당한 코드로 분기된다. isErrorClass 로 먼저 걸러서 이 오분류를 막는다.

(2) Error 가 아닌 값도 안전 처리

JS 에서는 throw 'string' 처럼 Error 가 아닌 값을 던질 수 있다. 그러면 thrown.message 가 undefined 다.

String(thrown?.message ?? thrown).includes(expected)

?.message ?? thrown 한 줄이 이 케이스를 받아낸다 — message 가 없으면 던져진 값 자체를 문자열화한다.

📋 자연어 에러 메시지

분기가 다섯 개라면 실패 시 보여줄 메시지도 다섯 개여야 한다. 그래서 formatThrowErrorMsg 가 expected 형태별로 다른 문구를 만든다.

const describeThrowExpected = (expected) => {
  if (expected === undefined)        return 'to throw an error';
  if (typeof expected === 'string')  return `to throw an error containing "${expected}"`;
  if (expected instanceof RegExp)    return `to throw an error matching ${expected}`;
  if (typeof expected === 'function') {
    if (expected === Error || expected.prototype instanceof Error) {
      return `to throw an instance of ${expected.name || 'Error'}`;
    }
    return 'to throw an error matching predicate';
  }
  return 'to throw an error';
};

같은 매처가 어떤 인자를 받았느냐에 따라 자연어로 자기를 설명한다. 사용자가 메시지만 보고도 무엇이 어긋났는지 알 수 있다.

💡 mock 호출 추적과 toHaveBeenCalled*

다음으로 손본 건 mock 함수의 추적성이다. 매처를 아무리 늘려도 mock 이 자기 호출 이력을 기억하지 않으면 toHaveBeenCalled* 같은 매처를 만들 수 없다.

📋 makeMockFncstate.calls 추가

export const makeMockFnc = (implementation = (() => null)) => {
  const state = {
    returnQueue: [],
    curImplement: implementation,
    calls: [],          // ← 추가
  };

  const mockFn = (...args) => {
    state.calls.push(args);   // ← 호출마다 인자 배열을 push
    if (state.returnQueue.length > 0) {
      return state.returnQueue.shift();
    }
    return state.curImplement(...args);
  };

  Object.assign(mockFn, createMockMethods(mockFn, state));
  mockFn.mock = {calls: state.calls};   // ← 같은 배열을 외부에 노출

  return mockFn;
};

호출이 일어날 때마다 인자 배열 그대로 state.calls 에 push 하고, mockFn.mock.calls 라는 이름으로 같은 배열을 외부에 노출한다. Jest 의 mock.calls 와 정확히 같은 모양이다.

📋 mockClearlength = 0 인 이유

여기서 한 가지 함정이 있다. mock 을 비우는 mockClear 의 본체가 이렇게 생겼다.

mockClear() {
  state.returnQueue = [];
  state.curImplement = () => null;
  state.calls.length = 0;   // ← in-place 비우기
  return mockFn;
},

state.calls.length = 0 으로 in-place 비우기를 한다. 처음에는 state.calls = [] 가 더 자연스러워 보였다.

state.calls = [];           // ❌ 안 됨

왜 이게 문제냐면, mockFn.mock = {calls: state.calls}mockClear 호출 시점의 그 배열을 참조하고 있기 때문이다. state.calls 에 새 배열을 재할당하면 statemockFn.mock.calls 가 가리키는 배열이 어긋난다 — 사용자가 clear() 하고 나서 보는 mock.calls 는 옛 배열 그대로다.

length = 0 은 같은 배열의 내용물만 비우니, 양쪽이 보던 참조가 그대로 유지된다. 참조를 외부에 그대로 노출하는 설계의 작은 대가다.

📋 그 위에 얹힌 세 매처

mock.calls 가 노출되고 나면 매처 세 개는 단순한 어댑터 수준으로 떨어진다.

// toHaveBeenCalled
export const toHaveBeenCalled = (actual) => {
  const callCount = actual?.mock?.calls?.length ?? 0;
  return {pass: callCount > 0, message: () => ...};
};

// toHaveBeenCalledTimes
export const toHaveBeenCalledTimes = (actual, expected) => {
  const callCount = actual?.mock?.calls?.length ?? 0;
  return {pass: callCount === expected, message: () => ...};
};

// toHaveBeenCalledWith
export const toHaveBeenCalledWith = (actual, ...expectedArgs) => {
  const calls = actual?.mock?.calls ?? [];
  return {
    pass: calls.some((callArgs) => deepEqual(callArgs, expectedArgs)),
    message: () => ...,
  };
};

세 매처 모두 본체가 짧다. 무거운 일은 mock 쪽이 다 끝내놓고, 매처는 결과를 묻기만 한다.

toHaveBeenCalledWith 의 비교는 === 가 아닌 deepEqual 인데, 이게 다음 절의 주제와 직접 이어진다.

💡 새 매처 묶음

시그니처가 통일되자 그동안 미뤄둔 매처들을 한꺼번에 추가했다. 각 매처 본체는 한두 줄짜리지만, 사용자 코드에서 명령 한 줄로 끝나는 검증의 수가 크게 늘었다.

매처의미
toContain배열·문자열의 .includes 위임
toBeInstanceOf(Ctor)actual instanceof Ctor
toBeNullactual === null
toBeUndefinedactual === undefined
toBeDefinedactual !== undefined

각 매처가 가져다주는 효과는 비슷하다 — 사용자가 if/throw 로 작성하던 한 토막을 한 줄짜리 의도 표현으로 바꿔준다. 그리고 앞에서 본 시그니처 통일 덕분에 .not.toContain, .not.toBeInstanceOf 같은 부정 케이스도 자동으로 따라온다.

💡 JSON.stringify 비교의 한계

마지막으로 손본 건 가장 깊은 문제 — toEqual 의 비교 방식 자체였다 (v0.6.0).

📋 두 가지 함정

const a = {x: 1, y: 2};
const b = {y: 2, x: 1};
JSON.stringify(a) === JSON.stringify(b);   // false ❌

같은 데이터인데 키 순서가 달라서 다르다고 판단한다.

const obj = {self: null};
obj.self = obj;
JSON.stringify(obj);   // TypeError ❌

순환 참조가 있으면 비교는 시작도 못 한다.

테스트는 사용자의 평범한 코드를 검증하는 도구다. 데이터에 순환이 있다는 이유만으로 매처가 먼저 죽는 건, 라이브러리가 사용자의 코드를 제약하는 방향이다.

📋 재귀 deepEqual 도입

해결은 직접 비교 함수를 짜는 것이었다. src/expect/matchers/utils/deepEqual.js.

export const deepEqual = (a, b, seen = new WeakMap()) => {
  if (Object.is(a, b)) return true;
  if (a === null || b === null) return false;
  if (typeof a !== 'object' || typeof b !== 'object') return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  if (seen.has(a)) return seen.get(a) === b;
  seen.set(a, b);

  if (Array.isArray(a)) {
    if (a.length !== b.length) return false;
    return a.every((item, i) => deepEqual(item, b[i], seen));
  }

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every(
    (k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k], seen),
  );
};

이 21 줄짜리 함수가 다섯 가지 결정을 담고 있다.

  • Object.is 로 시작 — NaN === NaN 도 같다고 인정 (=== 는 false 를 돌려준다)
  • prototype 비교{a:1}Object.create(null, {a:{value:1, enumerable:true}}) 처럼 프로토타입이 다른 두 객체는 다르다고 본다
  • WeakMap 으로 cycle 추적 — 한 번 본 쌍은 같다고 가정하고 진행 (seen.set(a, b)). 무한 루프가 끝난다
  • 키 순서 무관keysA.every + hasOwnProperty.call(b, k) 로 키 존재 여부와 값을 분리해서 검사
  • 재귀 인자로 seen 전파 — 깊은 트리에서도 같은 cycle 추적이 유지된다

키 순서 차이도, 순환 참조도, 이 한 함수에서 자연스럽게 처리된다.

toEqual 뿐 아니라 toHaveBeenCalledWith 도 같은 deepEqual 을 쓴다. mock 호출 인자 비교가 같은 안전성 위에 얹힌다.

📋 safeStringify — 에러 메시지까지 안전하게

deepEqual 이 비교 자체는 안전해도, 실패 시 메시지를 만드는 단계에서 또 JSON.stringify 가 등장하면 거기서 다시 터진다.

// src/view/safeStringify.js
export const safeStringify = (value) => {
  const seen = new WeakSet();
  return JSON.stringify(value, (_key, val) => {
    if (typeof val === 'object' && val !== null) {
      if (seen.has(val)) return '[Circular]';
      seen.add(val);
    }
    return val;
  });
};

JSON.stringify 의 두 번째 인자(replacer)로 한 번 본 객체에 [Circular] 라벨을 붙인다. 순환이 있어도 메시지 출력 자체는 멈추지 않는다.

formatErrorMsg, formatContainErrorMsg, formatCalledWithErrorMsg 등 메시지 함수들이 모두 safeStringify 를 거친다 — 비교 단계의 안전성이 출력 단계까지 일관되게 이어진다.

😅 후기

이번 작업은 화려한 신기능 한 방이 아니라 자잘한 매처들이 모인 한 라운드였다. 그런데도 끝나고 보니, 라이브러리의 expect 표면이 비로소 사용자가 익숙한 Jest 와 비슷한 느낌으로 채워졌다.

흐름을 거꾸로 정리하면 분명한 순서가 보인다.

  1. 구조 — 매처 시그니처를 {pass, message} 로 통일하고 .not 을 자동 생성
  2. 표현력toThrow 다형화 / mock.calls 추가 / 신규 매처들
  3. 안전성JSON.stringify 비교를 deepEqual 로 교체, 메시지 출력은 safeStringify

구조 변경(1)이 없었다면 매처가 늘 때마다 expect() 본문과 .not 분기를 같이 손대야 해서 (2) 의 속도가 절반으로 떨어졌을 것이다. 그리고 (1) 위에서 매처가 빠르게 늘어나니, (3) 처럼 비교 알고리즘 자체를 통째로 바꾸는 작업도 한 군데(utils/deepEqual.js)만 손대면 끝나게 됐다.

작업 단위 하나하나는 작아도, 무엇을 먼저 하느냐가 다음 작업의 비용을 결정한다는 걸 다시 확인한 라운드였다.


매처 자체의 품질이 어느 정도 자리 잡고 나니, 이번엔 다른 곳이 비어 있는 게 보였다. 테스트 파일이 늘어날수록 "이 한 파일만" 또는 "이 한 테스트만" 돌리고 싶다는 욕구가 커진다.

다음 포스트에서는 CLI 인자 파서를 새로 도입하고, 파일 필터·이름 필터를 단계적으로 얹어 라이브러리를 부분 실행까지 끌어올린 과정을 다룰 예정이다.

📎 프로젝트 링크

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

0개의 댓글