Skip to main content

Matcher로 다양한 값을 검증하기

Views 5약 11분생성형 AI 활용

expect 다음에 붙는 말이 테스트의 의미를 만든다

테스트 코드를 처음 보면 이런 문장이 자주 등장합니다.

expect(result).toBe(10);

이 문장은 거의 영어처럼 읽을 수 있습니다.

"result가 10이기를 기대한다."

여기서 expect(result)는 검증하고 싶은 실제 값을 Jest에게 건네는 부분입니다. 그리고 뒤에 붙은 toBe(10)이 바로 matcher입니다. matcher는 "어떤 방식으로 비교할 것인지"를 정합니다.

같은 값이라도 무엇을 확인하고 싶은지에 따라 matcher는 달라집니다.

expect(count).toBe(3);
expect(user.name).toBe('Kim');
expect(items).toHaveLength(5);
expect(price).toBeGreaterThan(0);
expect(fn).toThrow();

겉으로 보기에는 모두 비슷한 테스트 코드처럼 보이지만, 각각 확인하는 의도는 다릅니다. 숫자가 정확히 같은지, 문자열이 같은지, 배열의 길이가 맞는지, 특정 값보다 큰지, 함수가 에러를 던지는지 확인하고 있습니다.

이번 장의 핵심은 단순합니다.

expect 뒤에 무엇을 붙이느냐에 따라 테스트의 표현력이 크게 달라집니다.

matcher를 잘 고르면 테스트 코드는 단순히 "통과하거나 실패하는 코드"를 넘어서, "이 코드가 어떤 조건을 만족해야 하는지 설명하는 문서"가 됩니다.

Matcher로 다양한 값을 검증하기

toBe: 원시값을 정확히 비교하기

가장 먼저 만나는 matcher는 보통 toBe입니다. toBe는 값을 정확히 같은 값인지 비교할 때 사용합니다.

test('두 수를 더한다', () => {
  const result = 2 + 3;

  expect(result).toBe(5);
});

숫자뿐 아니라 문자열, 불리언 값도 자연스럽게 비교할 수 있습니다.

test('사용자 이름을 반환한다', () => {
  const name = 'Jest';

  expect(name).toBe('Jest');
});

test('로그인 상태를 확인한다', () => {
  const isLoggedIn = true;

  expect(isLoggedIn).toBe(true);
});

toBe는 원시값을 비교할 때 가장 직관적입니다. 여기서 원시값이란 숫자, 문자열, 불리언, null, undefined 같은 단순한 값을 말합니다.

그렇다면 객체나 배열도 toBe로 비교할 수 있을까요?

한번 예상해 봅시다.

test('객체를 비교한다', () => {
  const user = { name: 'Kim', age: 20 };

  expect(user).toBe({ name: 'Kim', age: 20 });
});

언뜻 보면 통과할 것 같습니다. user도 { name: 'Kim', age: 20 }이고, 기대값도 { name: 'Kim', age: 20 }이기 때문입니다.

하지만 이 테스트는 실패합니다.

왜일까요?

객체와 배열은 생긴 모양이 같아도, 메모리상에서는 서로 다른 존재일 수 있습니다. toBe는 객체의 내부 내용을 꼼꼼히 비교하기보다는 "정말 같은 대상인가?"를 확인합니다.

비유하자면, 같은 모양의 열쇠고리 두 개가 있어도 둘은 서로 다른 물건입니다. toBe는 "두 열쇠고리가 똑같이 생겼는가?"가 아니라 "이 둘이 같은 하나의 물건인가?"에 가깝게 묻습니다.

test('같은 객체를 가리키면 toBe는 통과한다', () => {
  const user = { name: 'Kim', age: 20 };
  const sameUser = user;

  expect(sameUser).toBe(user);
});

이 경우에는 sameUser와 user가 같은 객체를 가리키므로 테스트가 통과합니다.

정리하면 toBe는 숫자, 문자열, 불리언 같은 원시값을 비교할 때 주로 사용하면 좋습니다. 객체나 배열의 내용을 비교하고 싶다면 다른 matcher가 필요합니다.

toEqual: 객체와 배열의 내용을 비교하기

객체나 배열의 "내용"을 비교하고 싶을 때는 toEqual을 사용합니다.

test('사용자 객체를 비교한다', () => {
  const user = {
    name: 'Kim',
    age: 20,
  };

  expect(user).toEqual({
    name: 'Kim',
    age: 20,
  });
});

이번에는 통과합니다. toEqual은 객체가 같은 대상인지보다, 안에 들어 있는 값들이 같은지 확인합니다.

배열도 마찬가지입니다.

test('장바구니 목록을 비교한다', () => {
  const cart = ['apple', 'banana', 'orange'];

  expect(cart).toEqual(['apple', 'banana', 'orange']);
});

배열 안에 객체가 들어 있어도 비교할 수 있습니다.

test('주문 목록을 비교한다', () => {
  const orders = [
    { id: 1, product: 'keyboard' },
    { id: 2, product: 'mouse' },
  ];

  expect(orders).toEqual([
    { id: 1, product: 'keyboard' },
    { id: 2, product: 'mouse' },
  ]);
});

실무에서 toEqual은 꽤 자주 사용됩니다. 함수가 객체를 반환하거나, 배열을 가공하거나, API 응답 형태를 정리하는 경우가 많기 때문입니다.

예를 들어 사용자 이름을 받아 프로필 객체를 만드는 함수가 있다고 해봅시다.

function createProfile(name) {
  return {
    name,
    role: 'user',
    active: true,
  };
}

test('기본 사용자 프로필을 만든다', () => {
  expect(createProfile('Lee')).toEqual({
    name: 'Lee',
    role: 'user',
    active: true,
  });
});

이 테스트는 단순히 값 하나를 확인하는 것이 아니라, 함수가 만들어내는 결과의 구조 전체를 확인합니다.

toStrictEqual: 더 엄격하게 비교하기

toEqual과 비슷하지만 더 엄격한 matcher가 있습니다. 바로 toStrictEqual입니다.

초보 단계에서는 처음부터 모든 차이를 외울 필요는 없습니다. 우선 이렇게 이해하면 충분합니다.

toEqual은 객체와 배열의 내용을 비교할 때 사용하고, toStrictEqual은 그보다 더 엄격하게 "정말 같은 형태인가?"까지 확인합니다.

차이를 느끼기 위해 예제를 보겠습니다.

test('undefined 속성을 가진 객체 비교', () => {
  const user = {
    name: 'Kim',
    age: undefined,
  };

  expect(user).toEqual({
    name: 'Kim',
  });
});

toEqual은 경우에 따라 이런 차이를 느슨하게 보기도 합니다. 하지만 toStrictEqual은 더 까다롭게 봅니다.

test('undefined 속성까지 엄격하게 비교한다', () => {
  const user = {
    name: 'Kim',
    age: undefined,
  };

  expect(user).toStrictEqual({
    name: 'Kim',
  });
});

이 테스트는 실패합니다. user에는 age라는 속성이 존재하고 그 값이 undefined입니다. 반면 기대값 객체에는 age 속성 자체가 없습니다. toStrictEqual은 이 둘을 다르게 봅니다.

배열에서도 비슷한 차이를 만날 수 있습니다.

test('비어 있는 배열 칸을 엄격하게 비교한다', () => {
  const numbers = [];
  numbers[1] = 2;

  expect(numbers).toStrictEqual([undefined, 2]);
});

배열의 빈 칸과 undefined가 들어 있는 칸은 겉보기에는 비슷해 보일 수 있지만, 엄격히 보면 다릅니다. toStrictEqual은 이런 차이를 잡아내는 데 도움이 됩니다.

그렇다면 언제 무엇을 쓰면 좋을까요?

일반적인 객체와 배열의 결과를 비교할 때는 toEqual로 시작해도 괜찮습니다. 하지만 데이터 구조가 정확해야 하는 코드, 예를 들어 서버에 보낼 요청 객체나 저장소에 기록할 데이터처럼 속성 하나의 유무도 중요한 경우에는 toStrictEqual이 더 적합합니다.

function createRequestBody(email) {
  return {
    email,
    marketingAgreed: false,
  };
}

test('회원 가입 요청 본문을 정확히 만든다', () => {
  expect(createRequestBody('[email protected]')).toStrictEqual({
    email: '[email protected]',
    marketingAgreed: false,
  });
});

이 테스트는 email 값뿐 아니라 요청 본문이 정확히 어떤 속성으로 구성되는지도 확인합니다.

값이 존재하는지 확인하기

테스트를 하다 보면 "값이 정확히 무엇인지"보다 "값이 있느냐 없느냐"가 더 중요할 때가 있습니다.

예를 들어 로그인 함수가 토큰 문자열을 반환한다고 해봅시다. 토큰의 실제 값은 매번 달라질 수 있습니다. 이럴 때는 정확한 문자열을 비교하기 어렵습니다. 대신 "비어 있지 않은 값이 반환되었는지"를 확인할 수 있습니다.

Jest에는 이런 상황을 위한 matcher가 있습니다.

expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();

하나씩 살펴보겠습니다.

toBeTruthy와 toBeFalsy: 참처럼 보이는 값, 거짓처럼 보이는 값

JavaScript에는 true 자체는 아니지만 조건문에서 참처럼 동작하는 값들이 있습니다. 예를 들어 비어 있지 않은 문자열, 0이 아닌 숫자, 객체, 배열은 조건문에서 참처럼 취급됩니다.

if ('hello') {
  // 실행됨
}

if ([]) {
  // 실행됨
}

이런 값을 흔히 truthy한 값이라고 부릅니다. 반대로 false, 0, '', null, undefined, NaN처럼 조건문에서 거짓처럼 동작하는 값은 falsy한 값이라고 부릅니다.

toBeTruthy는 값이 조건문에서 참처럼 동작하는지 확인합니다.

function createToken(userId) {
  return `token-${userId}`;
}

test('토큰을 생성한다', () => {
  const token = createToken(10);

  expect(token).toBeTruthy();
});

이 테스트는 토큰이 정확히 어떤 문자열인지보다, 토큰으로 사용할 만한 값이 생성되었는지를 확인합니다.

반대로 toBeFalsy는 값이 조건문에서 거짓처럼 동작하는지 확인합니다.

function findUserById(id) {
  const users = [{ id: 1, name: 'Kim' }];
  return users.find((user) => user.id === id);
}

test('존재하지 않는 사용자를 찾으면 falsy한 값이 나온다', () => {
  const user = findUserById(999);

  expect(user).toBeFalsy();
});

여기서 Array.prototype.find는 조건에 맞는 값을 찾지 못하면 undefined를 반환합니다. undefined는 falsy한 값이므로 테스트가 통과합니다.

다만 toBeTruthy와 toBeFalsy는 편리한 만큼 다소 넓은 표현입니다. 0, 빈 문자열, null, undefined가 모두 falsy이기 때문에, 정확히 어떤 값이어야 하는지가 중요하다면 더 구체적인 matcher를 쓰는 편이 좋습니다.

toBeNull과 toBeUndefined: 비어 있음의 종류 구분하기

null과 undefined는 둘 다 "값이 없다"는 느낌을 줍니다. 하지만 의미는 조금 다릅니다.

undefined는 보통 아직 값이 할당되지 않았거나, 찾는 속성이 없거나, 함수가 아무것도 반환하지 않았을 때 등장합니다. 반면 null은 개발자가 의도적으로 "값이 없음"을 넣어둔 경우에 자주 사용됩니다.

const user = {
  name: 'Kim',
  profileImage: null,
};

console.log(user.age); // undefined

위 예제에서 profileImage는 속성이 존재하지만 값이 null입니다. "프로필 이미지가 없다"는 사실을 의도적으로 표현한 것입니다. 반면 age는 속성 자체가 없기 때문에 undefined입니다.

Jest에서는 이 차이를 명확하게 테스트할 수 있습니다.

test('프로필 이미지가 없으면 null을 사용한다', () => {
  const user = {
    name: 'Kim',
    profileImage: null,
  };

  expect(user.profileImage).toBeNull();
});
test('없는 속성은 undefined다', () => {
  const user = {
    name: 'Kim',
  };

  expect(user.age).toBeUndefined();
});

함수가 명시적으로 null을 반환하도록 설계되어 있다면 toBeNull을 쓰는 것이 좋습니다.

function getSelectedItem(items) {
  if (items.length === 0) {
    return null;
  }

  return items[0];
}

test('목록이 비어 있으면 선택된 항목은 null이다', () => {
  expect(getSelectedItem([])).toBeNull();
});

반대로 함수가 아무 값도 반환하지 않는 것이 의도라면 toBeUndefined를 사용할 수 있습니다.

function logMessage(message) {
  console.log(message);
}

test('로그 함수는 값을 반환하지 않는다', () => {
  const result = logMessage('hello');

  expect(result).toBeUndefined();
});

테스트는 코드의 의도를 드러내야 합니다. "아무 값도 없으면 되지"라고 뭉뚱그리기보다, 정말 null이어야 하는지, undefined여야 하는지 구분하면 테스트가 더 정확해집니다.

숫자 비교하기: 정확함보다 의도가 중요할 때

숫자를 비교할 때도 toBe를 사용할 수 있습니다.

test('할인 가격을 계산한다', () => {
  const price = 10000;
  const discount = 1000;

  expect(price - discount).toBe(9000);
});

이런 단순한 정수 계산은 toBe로 충분합니다. 하지만 모든 숫자 비교가 "정확히 같다"만으로 표현되지는 않습니다.

예를 들어 나이가 18세 이상인지, 결제 금액이 0보다 큰지, 점수가 특정 기준을 넘었는지 확인해야 할 수 있습니다. 이때는 비교 의도를 더 잘 드러내는 matcher를 사용하는 것이 좋습니다.

toBeGreaterThan: 특정 값보다 큰지 확인하기

toBeGreaterThan은 실제 값이 기대값보다 큰지 확인합니다.

function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

test('장바구니 총액은 0보다 크다', () => {
  const items = [
    { name: 'keyboard', price: 30000 },
    { name: 'mouse', price: 15000 },
  ];

  const total = calculateTotal(items);

  expect(total).toBeGreaterThan(0);
});

이 테스트는 총액이 정확히 45000인지 확인하지 않습니다. 대신 장바구니에 상품이 있을 때 총액이 0보다 커야 한다는 조건을 확인합니다.

정확한 합계가 중요하다면 toBe(45000)이 더 좋습니다. 하지만 이 테스트의 목적이 "양수인지 확인하는 것"이라면 toBeGreaterThan(0)이 의도를 더 잘 보여줍니다.

비슷한 matcher로는 toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual도 있습니다. 이름이 길지만 읽어보면 의미가 그대로 드러납니다.

test('성인은 18세 이상이다', () => {
  const age = 18;

  expect(age).toBeGreaterThanOrEqual(18);
});

이처럼 matcher 이름이 길어도 겁먹을 필요는 없습니다. Jest matcher는 대부분 "문장처럼 읽히게" 만들어져 있습니다.

toBeCloseTo: 소수 계산을 안전하게 비교하기

JavaScript에서 소수 계산은 가끔 낯선 결과를 보여줍니다.

console.log(0.1 + 0.2); // 0.30000000000000004

우리는 0.1 + 0.2가 0.3이라고 생각하지만, 컴퓨터 내부의 부동소수점 계산 방식 때문에 아주 작은 오차가 생길 수 있습니다. 부동소수점이란 소수를 컴퓨터가 표현하는 방식이라고 이해하면 됩니다.

그래서 다음 테스트는 실패할 수 있습니다.

test('소수를 더한다', () => {
  expect(0.1 + 0.2).toBe(0.3);
});

이럴 때는 toBeCloseTo를 사용합니다.

test('소수를 더한다', () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3);
});

toBeCloseTo는 숫자가 완전히 같은지 보지 않고, 충분히 가까운지 확인합니다. 금액 계산, 비율 계산, 평균 계산처럼 소수가 등장하는 코드에서 유용합니다.

function calculateAverage(numbers) {
  const sum = numbers.reduce((total, number) => total + number, 0);
  return sum / numbers.length;
}

test('평균을 계산한다', () => {
  const average = calculateAverage([0.1, 0.2, 0.3]);

  expect(average).toBeCloseTo(0.2);
});

단, 돈을 다루는 실제 서비스에서는 소수 계산을 더 조심해야 합니다. 예를 들어 원 단위 정수로 바꿔 계산하거나, 전용 라이브러리를 사용하는 방식이 필요할 수 있습니다. 테스트에서는 우선 "소수 결과는 toBe보다 toBeCloseTo가 안전하다"는 감각을 익혀두면 좋습니다.

배열 검증하기: 전부가 아니라 일부를 확인하고 싶을 때

배열을 테스트할 때 매번 전체 배열을 비교해야 하는 것은 아닙니다.

expect(items).toEqual(['apple', 'banana', 'orange']);

이렇게 전체를 비교해야 할 때도 있지만, 어떤 경우에는 "특정 값이 포함되어 있는가?" 또는 "길이가 몇 개인가?"만 확인하면 충분합니다.

이때 유용한 matcher가 toContain과 toHaveLength입니다.

toContain: 배열에 특정 값이 들어 있는지 확인하기

toContain은 배열 안에 특정 값이 포함되어 있는지 확인합니다.

function getAvailableRoles() {
  return ['guest', 'user', 'admin'];
}

test('관리자 역할이 포함되어 있다', () => {
  const roles = getAvailableRoles();

  expect(roles).toContain('admin');
});

이 테스트는 역할 목록 전체가 정확히 어떤 순서로 구성되어 있는지는 관심이 없습니다. 중요한 것은 admin이라는 역할이 포함되어 있는지입니다.

문자열에서도 toContain을 사용할 수 있습니다.

test('환영 메시지에 사용자 이름이 포함된다', () => {
  const message = 'Hello, Kim! Welcome back.';

  expect(message).toContain('Kim');
});

문자열 전체가 정확히 같아야 한다면 toBe를 쓰면 됩니다. 하지만 일부 문구만 포함되면 되는 상황이라면 toContain이 더 자연스럽습니다.

다만 배열 안의 객체를 비교할 때는 주의가 필요합니다.

test('객체 배열에서 특정 객체를 찾는다', () => {
  const users = [{ id: 1, name: 'Kim' }];

  expect(users).toContain({ id: 1, name: 'Kim' });
});

이 테스트는 실패합니다. toContain은 객체의 내용이 같은지 깊게 비교하지 않고, 같은 객체인지 확인하기 때문입니다.

이런 경우에는 toContainEqual을 사용할 수 있습니다.

test('객체 배열에서 특정 객체를 찾는다', () => {
  const users = [{ id: 1, name: 'Kim' }];

  expect(users).toContainEqual({ id: 1, name: 'Kim' });
});

이번 장의 핵심 matcher 목록에는 toContainEqual이 포함되어 있지는 않지만, toContain을 쓰다 보면 자연스럽게 만나게 되는 친구입니다. 객체 배열을 다룰 때 기억해두면 좋습니다.

toHaveLength: 배열과 문자열의 길이 확인하기

toHaveLength는 배열이나 문자열의 길이를 확인합니다.

function getTopMenus() {
  return ['Home', 'Products', 'Contact'];
}

test('상단 메뉴는 3개다', () => {
  const menus = getTopMenus();

  expect(menus).toHaveLength(3);
});

물론 이렇게 쓸 수도 있습니다.

expect(menus.length).toBe(3);

둘 다 틀린 코드는 아닙니다. 하지만 toHaveLength(3)은 "이 값의 길이가 3이어야 한다"는 의도를 더 직접적으로 드러냅니다.

문자열에도 사용할 수 있습니다.

test('인증 코드는 6자리다', () => {
  const code = '482913';

  expect(code).toHaveLength(6);
});

인증 코드의 실제 숫자는 매번 바뀔 수 있습니다. 하지만 "6자리여야 한다"는 조건은 유지되어야 합니다. 이런 테스트에서는 toHaveLength가 잘 어울립니다.

객체 검증하기: 전체보다 중요한 속성 하나

객체를 테스트할 때도 전체 객체를 매번 비교할 필요는 없습니다.

예를 들어 사용자 객체에 여러 속성이 있다고 해봅시다.

const user = {
  id: 1,
  name: 'Kim',
  email: '[email protected]',
  role: 'admin',
  active: true,
};

어떤 테스트에서는 이 모든 속성이 중요하지 않을 수 있습니다. "관리자 권한을 가진 사용자인가?"만 확인하면 충분할 때도 있습니다.

이때 toHaveProperty를 사용할 수 있습니다.

toHaveProperty: 객체에 특정 속성이 있는지 확인하기

toHaveProperty는 객체가 특정 속성을 가지고 있는지 확인합니다.

test('사용자 객체에는 email 속성이 있다', () => {
  const user = {
    id: 1,
    name: 'Kim',
    email: '[email protected]',
  };

  expect(user).toHaveProperty('email');
});

속성이 존재하는지만 확인할 수도 있고, 속성의 값까지 함께 확인할 수도 있습니다.

test('사용자의 역할은 admin이다', () => {
  const user = {
    id: 1,
    name: 'Kim',
    role: 'admin',
  };

  expect(user).toHaveProperty('role', 'admin');
});

중첩된 객체도 확인할 수 있습니다.

test('사용자의 도시 정보를 확인한다', () => {
  const user = {
    id: 1,
    profile: {
      address: {
        city: 'Seoul',
      },
    },
  };

  expect(user).toHaveProperty('profile.address.city', 'Seoul');
});

이 테스트는 user.profile.address.city가 존재하고 값이 'Seoul'인지 확인합니다. 중첩 객체를 다룰 때 유용합니다.

다만 toHaveProperty를 너무 많이 나열하면 테스트가 장황해질 수 있습니다.

expect(user).toHaveProperty('id', 1);
expect(user).toHaveProperty('name', 'Kim');
expect(user).toHaveProperty('email', '[email protected]');
expect(user).toHaveProperty('role', 'admin');

이렇게 객체 전체 구조를 거의 다 검증하고 있다면 차라리 toEqual이나 toStrictEqual을 쓰는 편이 더 읽기 좋을 수 있습니다.

expect(user).toStrictEqual({
  id: 1,
  name: 'Kim',
  email: '[email protected]',
  role: 'admin',
});

matcher 선택은 결국 질문의 차이입니다.

"이 객체 전체가 정확히 같아야 하는가?"라면 toEqual 또는 toStrictEqual이 어울립니다. "이 객체에 특정 속성이 있는지만 확인하면 되는가?"라면 toHaveProperty가 어울립니다.

예외 검증하기: 실패해야 정상인 코드도 있다

좋은 코드는 올바른 입력을 잘 처리해야 합니다. 하지만 그게 전부는 아닙니다. 잘못된 입력이 들어왔을 때 조용히 이상한 결과를 내면 더 위험합니다.

예를 들어 나이를 입력받아 성인 여부를 판단하는 함수가 있다고 해봅시다.

function isAdult(age) {
  return age >= 18;
}

이 함수에 숫자가 아니라 문자열이 들어오면 어떻게 해야 할까요?

isAdult('hello');

그냥 false를 반환하게 둘 수도 있지만, 어떤 프로그램에서는 잘못된 입력을 즉시 알려주는 편이 더 안전합니다.

function isAdult(age) {
  if (typeof age !== 'number') {
    throw new Error('age must be a number');
  }

  return age >= 18;
}

이제 잘못된 입력이 들어오면 함수는 에러를 던집니다. 이런 동작도 테스트해야 합니다. "에러가 발생하면 테스트가 실패하는 것 아닌가?"라고 생각할 수 있지만, 아닙니다. 우리가 기대한 에러라면 그것은 정상 동작입니다.

이때 사용하는 matcher가 toThrow입니다.

toThrow: 에러가 발생하는지 확인하기

toThrow를 사용할 때 가장 중요한 규칙이 있습니다.

에러를 던지는 함수를 직접 실행하지 말고, 함수로 감싸서 expect에 전달해야 합니다.

올바른 예는 다음과 같습니다.

test('나이가 숫자가 아니면 에러를 던진다', () => {
  expect(() => isAdult('hello')).toThrow();
});

여기서 expect 안에는 isAdult('hello')의 실행 결과가 들어가는 것이 아닙니다. () => isAdult('hello')라는 함수가 들어갑니다. Jest는 이 함수를 직접 실행해보고, 그 과정에서 에러가 던져지는지 확인합니다.

잘못된 예도 보겠습니다.

test('나이가 숫자가 아니면 에러를 던진다', () => {
  expect(isAdult('hello')).toThrow();
});

이 코드는 의도대로 동작하지 않습니다. expect가 실행되기도 전에 isAdult('hello')가 먼저 실행되고, 그 순간 에러가 터져버리기 때문입니다. toThrow가 에러를 검사할 기회를 얻지 못합니다.

에러 메시지까지 확인할 수도 있습니다.

test('나이가 숫자가 아니면 안내 메시지를 담은 에러를 던진다', () => {
  expect(() => isAdult('hello')).toThrow('age must be a number');
});

이 테스트는 단순히 에러가 발생하는지만 확인하지 않습니다. 에러 메시지에 특정 문구가 들어 있는지도 확인합니다.

다른 예제를 하나 더 보겠습니다.

function divide(a, b) {
  if (b === 0) {
    throw new Error('cannot divide by zero');
  }

  return a / b;
}

test('0으로 나누면 에러를 던진다', () => {
  expect(() => divide(10, 0)).toThrow('cannot divide by zero');
});

이 테스트는 함수가 잘못된 계산을 조용히 진행하지 않고, 위험한 입력을 명확하게 거부하는지 확인합니다.

테스트는 성공하는 상황만 다루지 않습니다. 오히려 실패해야 정상인 상황을 테스트할 때 코드의 안정성이 크게 올라갑니다.

matcher를 고르는 작은 기준

matcher를 많이 보다 보면 처음에는 헷갈릴 수 있습니다. 하지만 모든 matcher를 외울 필요는 없습니다. 중요한 것은 "내가 지금 무엇을 확인하려는가?"를 먼저 묻는 것입니다.

값이 정확히 같은지 확인하려면 toBe를 생각합니다. 객체나 배열의 내용이 같은지 확인하려면 toEqual을 떠올립니다. 구조까지 더 엄격하게 확인하고 싶다면 toStrictEqual이 좋습니다.

값이 있는지 없는지 정도만 확인할 때는 toBeTruthy와 toBeFalsy를 사용할 수 있습니다. 다만 null이나 undefined처럼 값의 종류가 중요하다면 toBeNull, toBeUndefined처럼 더 구체적인 matcher가 낫습니다.

숫자를 비교할 때는 정확한 값이면 toBe, 범위나 기준이면 toBeGreaterThan 같은 비교 matcher를 사용합니다. 소수 계산처럼 작은 오차가 생길 수 있다면 toBeCloseTo가 안전합니다.

배열과 객체에서는 전체를 비교할지, 일부 조건만 확인할지 결정해야 합니다. 전체가 중요하면 toEqual이나 toStrictEqual, 특정 값 포함 여부는 toContain, 길이는 toHaveLength, 특정 속성은 toHaveProperty가 어울립니다.

예외 상황은 toThrow로 확인합니다. 이때는 반드시 에러를 던지는 코드를 함수로 감싸야 합니다.

하나의 기능을 여러 matcher로 테스트해보기

이제 여러 matcher를 한 흐름 안에서 사용해보겠습니다. 간단한 회원 가입 검증 함수를 만들어봅시다.

function createUser({ email, age, tags = [] }) {
  if (!email) {
    throw new Error('email is required');
  }

  if (typeof age !== 'number') {
    throw new Error('age must be a number');
  }

  return {
    email,
    age,
    active: true,
    tags,
    profile: {
      image: null,
    },
  };
}

이 함수는 이메일과 나이를 받아 사용자 객체를 만듭니다. 이메일이 없거나 나이가 숫자가 아니면 에러를 던집니다.

테스트를 작성해보겠습니다.

describe('createUser', () => {
  test('사용자 객체를 만든다', () => {
    const user = createUser({
      email: '[email protected]',
      age: 20,
      tags: ['new', 'event'],
    });

    expect(user).toStrictEqual({
      email: '[email protected]',
      age: 20,
      active: true,
      tags: ['new', 'event'],
      profile: {
        image: null,
      },
    });
  });

  test('사용자는 활성 상태다', () => {
    const user = createUser({ email: '[email protected]', age: 20 });

    expect(user.active).toBe(true);
  });

  test('나이는 0보다 크다', () => {
    const user = createUser({ email: '[email protected]', age: 20 });

    expect(user.age).toBeGreaterThan(0);
  });

  test('태그 목록을 가진다', () => {
    const user = createUser({
      email: '[email protected]',
      age: 20,
      tags: ['new', 'event'],
    });

    expect(user.tags).toHaveLength(2);
    expect(user.tags).toContain('new');
  });

  test('프로필 이미지는 아직 없다', () => {
    const user = createUser({ email: '[email protected]', age: 20 });

    expect(user).toHaveProperty('profile.image', null);
    expect(user.profile.image).toBeNull();
  });

  test('이메일이 없으면 에러를 던진다', () => {
    expect(() => createUser({ age: 20 })).toThrow('email is required');
  });

  test('나이가 숫자가 아니면 에러를 던진다', () => {
    expect(() =>
      createUser({ email: '[email protected]', age: 'twenty' })
    ).toThrow('age must be a number');
  });
});

같은 함수라도 테스트마다 관심사가 다릅니다. 첫 번째 테스트는 반환 객체 전체의 구조를 확인합니다. 두 번째 테스트는 활성 상태가 true인지 확인합니다. 세 번째 테스트는 나이가 양수인지 봅니다. 태그 테스트는 배열의 길이와 포함 여부를 확인합니다. 프로필 이미지 테스트는 중첩 속성과 null 값을 확인합니다. 마지막 두 테스트는 잘못된 입력에서 예외가 발생하는지 검증합니다.

이렇게 matcher를 상황에 맞게 선택하면 테스트가 훨씬 또렷해집니다.

너무 많은 것을 한 번에 검증하지 않기

matcher를 배우면 한 테스트 안에 여러 expect를 넣고 싶어질 수 있습니다. 물론 여러 개를 넣는 것이 항상 나쁜 것은 아닙니다. 하나의 동작을 설명하는 데 필요한 검증이라면 괜찮습니다.

test('사용자 객체를 만든다', () => {
  const user = createUser({ email: '[email protected]', age: 20 });

  expect(user.email).toBe('[email protected]');
  expect(user.age).toBe(20);
  expect(user.active).toBe(true);
});

하지만 서로 다른 관심사가 한 테스트에 너무 많이 섞이면, 실패했을 때 원인을 파악하기 어려워집니다.

test('사용자 기능 전체 테스트', () => {
  const user = createUser({ email: '[email protected]', age: 20 });

  expect(user.email).toBe('[email protected]');
  expect(user.age).toBe(20);
  expect(user.active).toBe(true);
  expect(user.tags).toHaveLength(0);
  expect(user.profile.image).toBeNull();
  expect(() => createUser({ age: 20 })).toThrow();
});

이 테스트는 너무 많은 이야기를 한 번에 하고 있습니다. 사용자 생성 결과도 확인하고, 태그도 확인하고, 프로필 이미지도 확인하고, 예외 상황도 확인합니다. 이런 테스트가 실패하면 "도대체 무엇이 깨진 거지?"라는 질문부터 하게 됩니다.

더 좋은 방식은 관심사별로 나누는 것입니다.

test('사용자 기본 정보를 만든다', () => {
  const user = createUser({ email: '[email protected]', age: 20 });

  expect(user).toHaveProperty('email', '[email protected]');
  expect(user).toHaveProperty('age', 20);
});

test('프로필 이미지는 기본값으로 null이다', () => {
  const user = createUser({ email: '[email protected]', age: 20 });

  expect(user.profile.image).toBeNull();
});

test('이메일이 없으면 에러를 던진다', () => {
  expect(() => createUser({ age: 20 })).toThrow('email is required');
});

테스트를 나눈다고 해서 무조건 좋은 것은 아니지만, 실패했을 때 이유가 선명해지는 방향으로 구성하는 것이 좋습니다. matcher는 그 선명함을 돕는 도구입니다.

실패 메시지를 읽는 습관

matcher를 잘못 선택하면 테스트 실패 메시지가 힌트를 줍니다. 예를 들어 객체를 toBe로 비교하면 Jest는 대개 "두 객체가 겉보기에는 비슷하지만 같은 참조가 아니다"라는 식의 정보를 보여줍니다.

expect({ name: 'Kim' }).toBe({ name: 'Kim' });

이런 테스트가 실패하면 당황하지 말고 질문해보세요.

"나는 같은 객체인지 확인하고 싶은 걸까, 아니면 객체의 내용이 같은지 확인하고 싶은 걸까?"

대부분의 경우 이 질문만으로도 toBe를 toEqual이나 toStrictEqual로 바꿔야 한다는 답을 얻을 수 있습니다.

소수 비교에서도 마찬가지입니다.

expect(0.1 + 0.2).toBe(0.3);

이 테스트가 실패하면 "계산이 완전히 틀렸나?"라고 생각하기 쉽지만, 실제로는 아주 작은 부동소수점 오차 때문일 수 있습니다. 이때는 toBeCloseTo를 떠올리면 됩니다.

테스트 실패는 불편하지만, 동시에 좋은 안내자입니다. 실패 메시지를 천천히 읽고 matcher가 의도에 맞는지 확인하는 습관을 들이면, 테스트 작성 실력이 빠르게 좋아집니다.

마무리: matcher는 테스트의 어휘다

이번 장에서는 Jest의 주요 matcher를 살펴봤습니다. matcher는 테스트 코드의 어휘와 같습니다. 아는 단어가 많아질수록 더 정확하고 자연스럽게 생각을 표현할 수 있습니다.

toBe만 알아도 간단한 테스트는 쓸 수 있습니다. 하지만 객체를 비교하려면 toEqual과 toStrictEqual이 필요합니다. 값의 존재 여부를 표현하려면 toBeTruthy, toBeFalsy, toBeNull, toBeUndefined가 도움이 됩니다. 숫자 비교에는 toBeGreaterThan과 toBeCloseTo가 유용합니다. 배열과 객체의 일부 조건을 확인할 때는 toContain, toHaveLength, toHaveProperty가 테스트를 읽기 쉽게 만들어줍니다. 잘못된 입력에서 에러가 발생해야 한다면 toThrow로 그 동작까지 검증할 수 있습니다.

중요한 것은 matcher 이름을 전부 외우는 것이 아닙니다. 테스트를 작성할 때 먼저 이렇게 물어보는 것입니다.

"나는 지금 무엇을 확인하고 싶은가?"

그 질문에 맞는 matcher를 고르면, 테스트는 훨씬 읽기 쉬워지고 실패했을 때도 이유가 선명해집니다. 다음 장에서는 이렇게 작성한 테스트를 더 편하게 실행하고, 개발 흐름 속에서 자연스럽게 돌리는 방법을 살펴보겠습니다.

책 목록

테스트 초보자를 위한 Jest 입문과 실전

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기