스냅샷 테스트

스냅샷 테스트 (Snapshot Testing)

UI가 의도치 않게 바뀌는 걸 막고 싶을 때, 스냅샷 테스트는 아주 유용한 도구예요. 동작은 간단해요. UI 컴포넌트를 렌더링하고 그 결과를 스냅샷으로 떠서, 테스트 옆에 저장해 둔 기준 스냅샷 파일과 비교하는 거죠. 둘이 일치하지 않으면 테스트가 실패하는데, 그건 예상치 못한 변경이거나 아니면 기준 스냅샷을 새 버전으로 갱신해야 한다는 뜻이에요.

이 글에서는 Jest로 스냅샷 테스트를 쓰고, 실패했을 때 갱신하고, 더 견고하게 만드는 방법까지 차례로 다룰게요.

출처: Jest 공식 문서 — Snapshot Testing

Jest로 스냅샷 테스트하기

React 컴포넌트를 테스트할 때도 비슷한 접근이 가능해요. 그래픽 UI 전체를 렌더링하려면 앱 전체를 빌드해야 하지만, 그 대신 테스트 렌더러를 사용해 컴포넌트를 직렬화 가능한 값으로 빠르게 만들어 낼 수 있죠. Link 컴포넌트를 위한 예제 테스트를 보면 이런 모습이에요.

import {render} from '@testing-library/react';
import Link from '../Link';

it('renders correctly', () => {
  const {container} = render(
    <Link page="http://www.facebook.com">Facebook</Link>,
  );
  expect(container.firstChild).toMatchSnapshot();
});

이 테스트를 처음 실행하면 Jest가 아래와 같은 스냅샷 파일을 만들어요.

exports[`renders correctly 1`] = `
<a
  aria-label="normal"
  class="normal"
  href="http://www.facebook.com"
>
  Facebook
</a>
`;

스냅샷 산출물은 코드 변경과 함께 커밋하고, 코드 리뷰 과정에서 함께 검토해야 해요. Jest는 pretty-format을 사용해 스냅샷을 사람이 읽기 쉽게 만들어 주죠. 이후 테스트를 실행할 때마다 렌더링 결과를 이전 스냅샷과 비교하고, 일치하면 통과하고 다르면 실패해요. 실패는 두 가지 중 하나를 뜻해요. (이 경우 <Link> 컴포넌트의) 코드에 고쳐야 할 버그가 생겼거나, 구현이 바뀌어서 스냅샷을 갱신해야 하거나요.

:::note

스냅샷은 렌더링한 데이터에 한정돼요. 예를 들어 page prop이 전달된 <Link> 컴포넌트의 스냅샷은 그 컴포넌트에만 적용되죠. 다른 파일에서 <Link>를 사용할 때 prop이 빠졌어도, 테스트는 <Link>의 사용처를 알지 못하므로 여전히 통과해요. 또 다른 스냅샷 테스트에서 같은 컴포넌트를 다른 prop으로 렌더링해도 서로에게 영향을 주지 않아요.

:::

:::info

스냅샷 테스트가 왜 필요하고 어떻게 동작하는지는 Jest 14 릴리스 블로그에서 더 자세히 볼 수 있어요. 언제 스냅샷 테스트를 써야 하는지 감을 잡으려면 이 블로그 글을, Jest로 스냅샷 테스트하는 실습은 egghead 영상을 추천해요.

:::

스냅샷 갱신하기 (Updating Snapshots)

버그가 생겼을 때 스냅샷 테스트가 실패하는 건 금방 알아채요. 그럴 땐 문제를 고치고 다시 통과시키면 되죠. 이제 의도적인 구현 변경 때문에 실패하는 경우를 살펴볼게요.

예를 들어 우리 예제에서 Link 컴포넌트가 가리키는 주소를 의도적으로 바꿨다고 해 봐요.

// Updated test case with a Link to a different address
it('renders correctly', () => {
  const {container} = render(
    <Link page="http://www.instagram.com">Instagram</Link>,
  );
  expect(container.firstChild).toMatchSnapshot();
});

이 경우 Jest는 갱신된 컴포넌트의 스냅샷이 더 이상 기준 스냅샷과 일치하지 않으므로 실패를 출력해요. 컴포넌트가 다른 주소를 가리키도록 방금 바꿨으니, 스냅샷이 바뀌는 건 당연한 일이죠. 해결하려면 스냅샷을 다시 생성하라고 알려주는 플래그를 주고 Jest를 실행하면 돼요.

jest --updateSnapshot

이 명령으로 변경을 수락하면 돼요. 같은 동작을 하는 짧은 -u 플래그를 써도 좋아요. 이 명령은 실패한 모든 스냅샷 테스트의 스냅샷을 다시 생성해 줘요. 만약 의도치 않은 버그 때문에 추가로 실패하는 스냅샷 테스트가 있다면, 버그 동작을 스냅샷으로 기록하지 않도록 먼저 버그를 고치고 재생성해야 해요.

일부 테스트 케이스만 재생성하고 싶다면 --testNamePattern 플래그를 추가해, 그 패턴에 맞는 테스트만 스냅샷을 다시 기록하게 할 수 있어요.

이 기능은 스냅샷 예제를 클론해 Link 컴포넌트를 수정하고 Jest를 돌려 직접 확인해 볼 수 있어요.

인터랙티브 스냅샷 모드

실패한 스냅샷은 watch 모드에서 인터랙티브하게 갱신할 수도 있어요. Interactive Snapshot Mode에 들어가면 Jest가 실패한 스냅샷을 테스트 하나씩 차례로 보여주면서, 그 실패한 출력을 검토할 기회를 줘요. 여기서 그 스냅샷을 갱신하거나 다음으로 건너뛰기를 선택할 수 있죠. 작업을 마치면 Jest가 요약을 보여준 뒤 watch 모드로 돌아가요.

인라인 스냅샷 (Inline Snapshots)

인라인 스냅샷은 외부 스냅샷(.snap 파일)과 동일하게 동작하지만, 스냅샷 값이 소스 코드에 자동으로 다시 기록된다는 점이 달라요. 외부 파일로 전환하지 않아도 자동 생성 스냅샷의 이점을 누릴 수 있죠.

먼저 인자 없이 .toMatchInlineSnapshot()을 호출하는 테스트를 작성해요.

it('renders correctly', () => {
  const {container} = render(
    <Link page="https://example.com">Example Site</Link>,
  );
  expect(container.firstChild).toMatchInlineSnapshot();
});

다음에 Jest를 실행하면 값이 평가되어, 스냅샷이 toMatchInlineSnapshot의 인자로 기록돼요.

it('renders correctly', () => {
  const {container} = render(
    <Link page="https://example.com">Example Site</Link>,
  );
  expect(container.firstChild).toMatchInlineSnapshot(`
    <a
      aria-label="normal"
      className="normal"
      href="https://example.com"
    >
      Example Site
    </a>
  `);
});

--updateSnapshot으로 갱신하거나 --watch 모드에서 u 키로 갱신할 수도 있어요. 기본적으로 Jest가 스냅샷을 소스 코드에 기록하지만, 프로젝트에서 prettier를 쓰고 있다면 Jest가 이를 감지해 (설정까지 존중하며) prettier에게 기록을 맡겨요.

프로퍼티 매처 (Property Matchers)

스냅샷을 찍고 싶은 객체에는 ID나 날짜처럼 매번 생성되는 필드가 있는 경우가 많아요. 이런 객체를 그대로 스냅샷으로 찍으면 실행할 때마다 실패하게 되죠.

it('will fail every time', () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: 'LeBron James',
  };

  expect(user).toMatchSnapshot();
});

// Snapshot
exports[`will fail every time 1`] = `
{
  "createdAt": 2018-05-19T23:36:09.816Z,
  "id": 3,
  "name": "LeBron James",
}
`;

이럴 때 Jest는 어떤 프로퍼티에든 **비대칭 매처(asymmetric matcher)**를 제공할 수 있어요. 이 매처들은 스냅샷이 기록되거나 테스트되기 전에 확인되고, 받은 값 대신 스냅샷 파일에 저장돼요.

it('will check the matchers and pass', () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: 'LeBron James',
  };

  expect(user).toMatchSnapshot({
    createdAt: expect.any(Date),
    id: expect.any(Number),
  });
});

// Snapshot
exports[`will check the matchers and pass 1`] = `
{
  "createdAt": Any<Date>,
  "id": Any<Number>,
  "name": "LeBron James",
}
`;

매처가 아닌 값은 정확히 일치하는지 확인되고 그대로 스냅샷에 저장돼요.

it('will check the values and pass', () => {
  const user = {
    createdAt: new Date(),
    name: 'Bond... James Bond',
  };

  expect(user).toMatchSnapshot({
    createdAt: expect.any(Date),
    name: 'Bond... James Bond',
  });
});

// Snapshot
exports[`will check the values and pass 1`] = `
{
  "createdAt": Any<Date>,
  "name": 'Bond... James Bond',
}
`;

:::tip

객체가 아니라 문자열을 다루는 경우에는 스냅샷을 찍기 전에 문자열의 임의 부분을 직접 치환해야 해요. 이때 replace()정규식을 활용하면 돼요.

const randomNumber = Math.round(Math.random() * 100);
const stringWithRandomData = `<div id="${randomNumber}">Lorem ipsum</div>`;
const stringWithConstantData = stringWithRandomData.replace(/id="\d+"/, 123);
expect(stringWithConstantData).toMatchSnapshot();

이 밖에도 스냅샷 시리얼라이저를 쓰거나, 스냅샷 대상 코드의 임의 부분을 생성하는 라이브러리를 목킹하는 방법도 있어요.

:::

모범 사례 (Best Practices)

스냅샷은 API 응답, UI, 로그, 에러 메시지처럼 애플리케이션 안에서 예상치 못한 인터페이스 변경을 찾아내는 훌륭한 도구예요. 다만 다른 테스트 전략과 마찬가지로, 효과적으로 쓰기 위한 몇 가지 원칙을 지켜야 해요.

1. 스냅샷을 코드처럼 취급하세요

스냅샷을 커밋하고, 일반적인 코드 리뷰 과정에서 함께 검토하세요. 즉 다른 테스트·코드와 똑같이 취급한다는 뜻이에요. 스냅샷이 읽기 쉽도록 집중적이고 짧게 유지하고, 이런 스타일 규칙을 강제하는 도구를 쓰세요.

앞서 말했듯 Jest는 pretty-format으로 사람이 읽기 쉽게 만들어 주지만, 짧고 집중적인 단언을 커밋하도록 돕는 추가 도구도 유용해요. 예를 들어 no-large-snapshots 옵션을 가진 eslint-plugin-jest나, 컴포넌트 스냅샷 비교 기능을 가진 snapshot-diff 같은 것들이죠. 목표는 풀 리퀘스트에서 스냅샷을 쉽게 리뷰하게 하고, 테스트 스위트가 실패할 때 원인을 살펴보지 않고 스냅샷을 무작정 재생성하는 습관을 막는 거예요.

2. 테스트는 결정적이어야 해요

같은 컴포넌트에 변경이 없는데 같은 테스트를 여러 번 돌려도 매번 같은 결과가 나와야 해요. 생성된 스냅샷에 플랫폼 특정 데이터나 비결정적 데이터가 섞이지 않도록 보장해야 하는 건 우리 책임이에요.

예를 들어 Date.now()를 사용하는 Clock 컴포넌트가 있다면, 그 스냅샷은 테스트를 실행할 때마다 달라져요. 이럴 때는 Date.now()를 목킹해서 매번 일정한 값을 반환하게 만들 수 있어요.

Date.now = jest.fn(() => 1_482_363_367_071);

이제 스냅샷 테스트가 실행될 때마다 Date.now()1482363367071을 일관되게 반환해서, 언제 실행하든 같은 스냅샷이 생성돼요.

3. 설명적인 스냅샷 이름을 쓰세요

스냅샷 테스트 이름은 항상 설명적으로 지으세요. 스냅샷 내용을 그대로 묘사하는 이름이 가장 좋아요. 그러면 리뷰어가 리뷰 중 스냅샷을 확인하기 쉽고, 오래된 스냅샷이 올바른 동작인지 갱신 전에 누구나 알 수 있어요.

예를 들어 이런 이름들보다,

exports[`<UserName /> should handle some test case`] = `null`;

exports[`<UserName /> should handle some other test case`] = `
<div>
  Alan Turing
</div>
`;

이렇게 출력물을 정확히 묘사하는 이름이 훨씬 좋아요.

exports[`<UserName /> should render null`] = `null`;

exports[`<UserName /> should render Alan Turing`] = `
<div>
  Alan Turing
</div>
`;

후자는 출력에서 정확히 무엇을 기대하는지 드러내서, 언제 틀렸는지 더 명확하게 보여주거든요.

exports[`<UserName /> should render null`] = `
<div>
  Alan Turing
</div>
`;

exports[`<UserName /> should render Alan Turing`] = `null`;

자주 묻는 질문

CI 시스템에서 스냅샷은 자동으로 기록되나요? 아니요. Jest 20부터 CI 환경에서 --updateSnapshot을 명시하지 않으면 스냅샷이 자동으로 기록되지 않아요. 모든 스냅샷은 CI에서 실행되는 코드의 일부로 간주되며, 새 스냅샷은 자동으로 통과하므로 CI에서 테스트 실행을 통과시키면 안 되죠. 모든 스냅샷을 항상 커밋하고 버전 관리에 유지하는 걸 권장해요.

스냅샷 파일을 커밋해야 하나요? 네. 모든 스냅샷 파일은 그것이 커버하는 모듈과 테스트와 함께 커밋해야 해요. Jest의 다른 단언처럼 테스트의 일부로 취급되죠. 실제로 스냅샷은 특정 시점의 소스 모듈 상태를 나타내기 때문에, 모듈이 수정되면 Jest가 이전 버전과 무엇이 달라졌는지 알려줘요. 리뷰어가 변경을 더 잘 살펴볼 수 있는 추가 맥락도 제공하죠.

스냅샷 테스트는 React 컴포넌트에서만 되나요? ReactReact Native 컴포넌트는 스냅샷 테스트의 좋은 사용 사례예요. 하지만 스냅샷은 직렬화 가능한 값이면 무엇이든 캡처할 수 있고, 출력이 올바른지 검증하는 게 목표라면 언제든 쓸 수 있어요. Jest 저장소 자체도 Jest의 출력, 단언 라이브러리의 출력, 여러 곳의 로그 메시지를 스냅샷으로 검증하죠. Jest 저장소의 CLI 출력 스냅샷 예제를 참고하세요.

스냅샷 테스트와 비주얼 리그레션 테스트의 차이는요? 둘 다 UI를 테스트하지만 목적이 달라요. 비주얼 리그레션 테스트는 웹 페이지의 스크린샷을 찍어 결과 이미지를 픽셀 단위로 비교해요. 반면 스냅샷 테스트는 값을 직렬화해 텍스트 파일에 저장하고 diff 알고리즘으로 비교하죠. 고려할 트레이드오프가 다르며, 스냅샷 테스트가 왜 만들어졌는지에 대한 이유는 Jest 블로그에서 확인할 수 있어요.

스냅샷 테스트가 유닛 테스트를 대체하나요? 스냅샷 테스트는 Jest에 포함된 20개가 넘는 단언 중 하나일 뿐이에요. 유닛 테스트를 대체하는 게 아니라 추가 가치를 제공하고 테스트를 수월하게 만드는 게 목표예요. React 컴포넌트 같은 특정 기능 집합에서는 유닛 테스트의 필요성을 줄일 수도 있지만, 둘은 함께 쓰일 수도 있죠.

성능은 어떤가요? Jest는 성능을 염두에 두고 다시 작성되었고 스냅샷 테스트도 예외가 아니에요. 스냅샷이 텍스트 파일에 저장되므로 이 방식의 테스트는 빠르고 안정적이죠. Jest는 toMatchSnapshot 매처를 호출하는 각 테스트 파일마다 새 파일을 생성해요. 스냅샷 크기도 꽤 작아서, Jest 코드베이스 자체의 모든 스냅샷 파일 크기는 300KB 미만이에요.

스냅샷 파일 충돌은 어떻게 해결하나요? 스냅샷 파일은 항상 그것이 커버하는 모듈의 현재 상태를 나타내야 해요. 따라서 두 브랜치를 병합하다 스냅샷 파일에서 충돌이 나면, 수동으로 해결하거나 Jest를 실행해 결과를 확인하면서 스냅샷 파일을 갱신하면 돼요.

TDD 원칙을 스냅샷 테스트에도 적용할 수 있나요? 스냅샷 파일을 수동으로 쓰는 것도 가능하지만 보통은 효과적이지 않아요. 스냅샷은 코드 설계에 방향을 주기보다는, 테스트가 커버하는 모듈의 출력이 변경됐는지 알아내는 데 도움을 주거든요.

스냅샷 테스트에서도 코드 커버리지가 동작하나요? 네, 다른 테스트와 똑같이 동작해요.

더 알아보기

스냅샷 테스트를 쓸 때 목 함수가 궁금하다면 Mock Functions을, 스냅샷 시리얼라이저 같은 설정값은 Configuration 문서를 참고하세요.