Jest에서 예외 타입 테스트하는 방법 — toThrow로 TypeError/message 검증

JavaScript 함수가 특정 예외를 던지는지, 그리고 그 예외의 타입(TypeError, ReferenceError 등) 까지 맞는지 테스트하는 것은 견고한 유닛 테스트의 필수 요소입니다. 이 글에서는 Jest에서 예외 타입과 메시지를 검증하는 방법을 5단계로 정리합니다. 특히 AVA(Test Anything)의 t.throws 문법에 익숙한 개발자가 Jest로 마이그레이션할 때 자주 헷갈리는 부분을 중심으로 설명합니다.

1. 문제 정의

기존에 AVA를 사용하던 환경에서는 t.throws의 두 번째 인자로 예외 타입을 넘겨 검증할 수 있었습니다.

1
2
3
4
5
6
7
8
// AVA 기준
it('should throw Error with message \'UNKNOWN ERROR\' when no params were passed', (t) => {
  const error = t.throws(() => {
    throwError();
  }, TypeError);

  t.is(error.message, 'UNKNOWN ERROR');
});

이 코드를 Jest로 다시 작성하려고 할 때, t.throws처럼 예외 타입을 직접 지정하는 문법이 눈에 띄지 않아 어떻게 해야 할지 막막한 상황입니다.

핵심 질문: “Jest에서 던져진 예외가 TypeError, ReferenceError 등 어떤 타입인지 테스트할 수 있는가?”

2. 원인 탐구

Jest에는 AVA의 t.throws와 1:1로 대응하는 “예외 타입을 두 번째 인자로 받는” 메서드가 없습니다. 대신 Jest는 매처(matcher) 방식, 즉 expect()함수를 전달하고 .toThrow() 매처를 호출하는 방식으로 동일한 검증을 수행합니다.

이 구조적 차이가 “Jest에서 예외 타입을 어떻게 테스트하지?“라는 혼란의 근본입니다. AVA는 함수를 실행하고 그 결과(던져진 예외)를 반환하는 반면, Jest는 expect실행하지 않은 함수 참조를 넘겨야 한다는 점이 핵심입니다.

3. 근본 원인 분석

혼란의 진짜 원인은 다음 두 가지입니다.

expect는 함수 자체를 받아야 한다

.toThrow()expect에 전달된 값이 (a) 실행됐을 때 예외를 던지는 함수이거나, (b) new Error에 넣을 수 있는 메시지 문자열, (c) 예외 생성자(Error subclass)여야 합니다. 만약 expect(x())처럼 함수를 호출한 결과를 넘기면, JavaScript는 expect가 실행되기 전에 그 줄에서 x()를 먼저 호출해 버려 예외가 테스트 코드 밖에서 즉시 발생하고 테스트가 실패합니다.

1
2
3
4
5
// ❌ 틀린 예 — x()가 먼저 실행되어 예외가 밖에서 발생
expect(throwError()).toThrow(TypeError);

// ✅ 올바른 예 — 함수 참조를 넘겨 toThrow가 내부에서 실행
expect(() => throwError()).toThrow(TypeError);

② AVA와 Jest의 문법적 관용구가 다르다

AVA는 t.throws(fn, TypeError)로 “타입을 두 번째 인자"로 받지만, Jest는 expect(fn).toThrow(TypeError)로 “타입을 매처의 인자"로 받습니다. 같은 목표를 향한 표현 방식의 차이일 뿐, 둘 다 예외 타입 검증이 가능합니다.

구분AVAJest
예외 타입 검증t.throws(fn, TypeError)expect(fn).toThrow(TypeError)
메시지 검증t.is(error.message, '...')expect(fn).toThrow('...')
전달 대상함수를 실행해 예외를 반환함수 참조를 expect에 전달

4. 코드 해결책

4-1. 예외 타입만 검증하기

expect(function).toThrow(예외 생성자) 형태로 함수를 전달합니다.

1
2
3
4
5
6
test("Test description", () => {
  const t = () => {
    throw new TypeError();
  };
  expect(t).toThrow(TypeError);
});

toThrow의 인자로 TypeError, ReferenceErrorError의 하위 클래스 생성자를 넣으면 해당 타입과 일치할 때만 통과합니다.

4-2. 예외 타입과 메시지 함께 검증하기

타입과 메시지를 각각 toThrow로 검증합니다. 같은 함수를 여러 번 호출해도 내부에서 예외를 던지기만 하면 되므로 안전합니다.

1
2
3
4
5
6
7
test("Test description", () => {
  const t = () => {
    throw new TypeError("UNKNOWN ERROR");
  };
  expect(t).toThrow(TypeError);       // 타입 검증
  expect(t).toThrow("UNKNOWN ERROR"); // 메시지 검증 (부분 일치)
});

toThrow("UNKNOWN ERROR")는 에러 메시지에 해당 문자열이 포함되어 있으면 통과합니다. 정확히 일치하는 메시지를 검증하고 싶다면 toThrow 대신 toThrowError 또는 정규식(/UNKNOWN ERROR/)을 활용할 수 있습니다.

4-3. 기존 함수(인자 포함) 테스트하기

이미 존재하는 함수가 특정 인자로 호출될 때 예외를 던지는지 확인하려면, 그 함수를 익명 함수로 감싸서 expect에 넘겨야 합니다.

1
2
3
4
5
test("Test description", () => {
  expect(() => {
    http.get(yourUrl, yourCallbackFn);
  }).toThrow(TypeError);
});

익명 함수 래퍼가 필요한 이유는 바로 위 “근본 원인 분석"에서 설명했듯, expect실행될 함수 참조를 요구하기 때문입니다. expect(http.get(yourUrl, yourCallbackFn)).toThrow(...)처럼 쓰면 http.get이 먼저 실행되어버립니다.

4-4. (참고) 비동기 함수의 예외 테스트

비동기 함수가 거부(reject)하는 경우에는 toThrow 대신 .rejects.toThrow를 사용합니다.

1
await expect(yourAsyncFn(...)).rejects.toThrow(TypeError);

5. 향후 예방 조치

  1. 항상 함수 참조를 넘긴다expect(비동기가 아닌 함수).toThrow(...)에서 괄호를 실수로 붙이지 않도록 expect(() => fn()) 형태의 익명 함수 래퍼를 기본 습관으로 삼습니다.
  2. 타입과 메시지를 각각 검증한다 — 타입만 확인하지 말고 toThrow(TypeError)toThrow('메시지')를 함께 사용해 더 명확한 실패 원인을 얻습니다.
  3. 검증 기준을 정한다 — 메시지가 자주 바뀐다면 전체 문자열보다 정규식(/UNKNOWN ERROR/)이나 부분 문자열로 검증해 테스트의 취약성을 줄입니다.
  4. 비동기 예외는 rejects를 사용한다 — async 함수는 await expect(...).rejects.toThrow(...)로 작성합니다.
  5. eslint-plugin-jest 사용 시 유의 — try/catch 기반 방식은 no-conditional-expect 규칙에 걸릴 수 있으므로, 기본적으로 toThrow 매처 방식을 권장합니다.

참고 출처: StackOverflow — How to test the type of a thrown exception in Jest (질문 46042613, 채택 답변 득표 845+)