본문으로 건너뛰기 Jest 실전 테스트 가이드 | 플레이키 테스트·Mock·Snapshot·Coverage 트러블슈팅

Jest 실전 테스트 가이드 | 플레이키 테스트·Mock·Snapshot·Coverage 트러블슈팅

Jest 실전 테스트 가이드 | 플레이키 테스트·Mock·Snapshot·Coverage 트러블슈팅

이 글의 핵심

Jest는 haste-map과 worker로 테스트를 병렬 실행하고, jest.mock 호이스팅과 모듈 레지스트리로 의존성을 격리합니다. 이 글에서는 Fake Timers, MSW, Snapshot, Coverage, --detectOpenHandles 진단까지 실무에서 자주 부딪히는 문제와 해결 패턴을 다룹니다.

옛날에 있던 팀에서 겪은 일입니다. 월요일 아침마다 같은 테스트가 가끔씩 빨간불로 바뀌었습니다. 로컬에서는 통과하는데 CI에서만 실패하거나, 반대로 CI는 초록인데 누군가의 노트북에서만 깨지는 식이었습니다. 원인을 찾느라 커피를 세 잔쯔 마신 날도 있었습니다. 결국 확인해 보니 Date.now()를 그대로 믿고 있었고, 테스트끼리 전역 싱글톤 모듈의 상태를 공유하고 있었습니다. 병렬 워커가 파일 실행 순서를 바꾸면서 레이스 컨디션이 드러난 것이었습니다. 그때 Fake Timers로 시간을 고정하고, MSW로 HTTP 응답을 결정적으로 만들고, --runInBand로 우선 재현부터 맞추는 세 가지 방법을 조합해 해결했습니다. 플레이키 테스트는 “운이 나쁜 CI” 문제가 아니라 거의 항상 테스트 설계가 의존하고 있는 비결정적 요소 때문에 발생합니다.

이 글은 Jest의 기본 문법을 처음부터 설명하기보다, 실제로 Jest를 도입해 운영하면서 자주 마주치는 문제와 그 해결 패턴을 다룹니다. Jest 자체의 개념과 설정을 차근차근 살펴보고 싶다면 Jest 완벽 가이드를 먼저 읽는 것을 추천합니다.

Jest는 무엇을 대신 해주는가

Jest는 JavaScript와 TypeScript를 위한 테스트 러너입니다. 설정이 거의 없이도 동작하는데, 이는 내부적으로 haste-map이라는 파일 인덱스를 사용해 프로젝트의 모듈 의존성을 추적하고, 여러 worker 프로세스로 테스트 파일을 병렬 실행하기 때문입니다. 테스트 파일이 많아질수록 이 병렬 실행의 효과가 커지지만, 동시에 전역 상태를 공유하는 테스트가 있다면 병렬성 자체가 버그의 원인이 될 수 있다는 점을 함께 기억해야 합니다.

Jest를 다룰 때 가장 먼저 이해해야 할 특성은 jest.mock이 호이스팅된다는 점입니다. 코드를 작성한 순서와 무관하게 Jest는 jest.mock 호출을 파일 상단으로 끌어올려 실제 import보다 먼저 실행합니다. 이 동작을 모르면 “왜 내가 만든 mock이 적용되지 않을까”라는 문제로 하루를 통째로 쓰게 되는 경우가 생각보다 자주 있습니다.

커버리지에 대한 생각도 짚어둘 필요가 있습니다. 100% 커버리지 자체는 품질을 보장하지 않습니다. 숫자를 맞추기 위해 의미 없는 assert를 늘리는 것은 시간 낭비이며, 중요한 분기와 도메인 규칙이 실제로 검증되고 있는지가 훨씬 중요합니다. 커버리지 수치는 “이 코드가 실행 과정에서 최소 한 번은 지나갔다”는 사실을 기록할 뿐, 그 결과가 올바른지 검증했다는 의미는 아닙니다.

설치와 기본 설정

프로젝트에 Jest를 추가하는 방법은 다음과 같습니다.

npm install -D jest @types/jest
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.test.{js,ts}',
  ],
};

testEnvironment는 테스트가 실행되는 전역 환경을 결정합니다. 순수한 로직 테스트라면 node 환경이 가장 빠르고, DOM API가 필요한 React 컴포넌트 테스트라면 jsdom으로 바꿔야 합니다. 두 환경을 파일 단위로 다르게 지정해야 하는 프로젝트라면 testEnvironment를 개별 테스트 파일 상단에 /** @jest-environment jsdom */ 주석으로 지정하는 방법도 있습니다.

기본적인 유닛 테스트 작성

가장 단순한 형태는 순수 함수 몇 개를 검증하는 테스트입니다.

// src/utils/math.ts
export function add(a: number, b: number): number {
  return a + b;
}
export function multiply(a: number, b: number): number {
  return a * b;
}
// src/utils/math.test.ts
import { add, multiply } from './math';

describe('Math utils', () => {
  test('add should sum two numbers', () => {
    expect(add(2, 3)).toBe(5);
    expect(add(-1, 1)).toBe(0);
  });

  test('multiply should multiply two numbers', () => {
    expect(multiply(2, 3)).toBe(6);
    expect(multiply(0, 5)).toBe(0);
  });
});

describe는 관련된 테스트를 하나의 그룹으로 묶는 역할을 하고, test(또는 it) 하나가 실제 검증 단위입니다. Matcher는 문서를 찾아보며 필요한 만큼 익혀도 충분하지만, toBe, toEqual, toThrow 세 가지만 정확히 구분해서 쓸 수 있어도 실무에서 마주치는 대부분의 상황을 커버할 수 있습니다. toBe는 원시값과 참조 동일성을 비교하고, toEqual은 객체나 배열의 값을 재귀적으로 비교하며, toThrow는 함수 호출이 예외를 던지는지 검증합니다.

비동기 테스트: async/await가 가장 안전하다

비동기 코드를 테스트할 때는 async/await를 사용하는 방식이 실수를 가장 적게 만듭니다. Promise를 return하지 않고 그대로 넘겨버리면, 테스트가 실제로는 실패해야 하는데도 가끔 통과하는 것처럼 보이는 위험한 패턴이 생깁니다. async/await를 쓰면 이런 문제가 구조적으로 사라집니다.

async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.status}`);
  }
  return response.json();
}

test('fetchUser should return user', async () => {
  const user = await fetchUser(1);
  expect(user).toHaveProperty('name');
  expect(user.id).toBe(1);
});

test('fetchUser should throw on error response', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('Failed to fetch user');
});

두 번째 테스트처럼 예외가 발생하는 경로도 반드시 검증해야 합니다. 실무에서 자주 놓치는 부분이 바로 에러 케이스인데, 정상 흐름만 테스트하면 실제 장애 상황에서 코드가 어떻게 동작하는지 아무도 확인하지 않은 채로 배포되는 셈입니다. rejects.toThrow처럼 Promise가 거부되는 경우를 다루는 매처를 적극적으로 활용하는 것이 좋습니다.

Mock: 타이밍과 경계를 정확히 이해하기

Mock은 jest.fn()으로 콜백을 흉내 내거나, 모듈 단위로는 jest.mock('./api')처럼 선언해서 사용합니다. 다만 앞서 설명한 것처럼 jest.mock은 호이스팅되어 import보다 먼저 실행되므로, mock 팩토리 안에서 외부 변수를 참조하려고 하면 초기화 순서 문제로 에러가 발생할 수 있습니다.

// src/services/notification.ts
import { sendEmail } from './email-client';

export async function notifyUser(userId: string, message: string) {
  await sendEmail(userId, message);
  return { notified: true };
}
// src/services/notification.test.ts
import { notifyUser } from './notification';
import { sendEmail } from './email-client';

jest.mock('./email-client', () => ({
  sendEmail: jest.fn().mockResolvedValue(undefined),
}));

describe('notifyUser', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('calls sendEmail with correct arguments', async () => {
    const result = await notifyUser('user-1', 'hello');

    expect(sendEmail).toHaveBeenCalledWith('user-1', 'hello');
    expect(result).toEqual({ notified: true });
  });

  test('propagates errors from sendEmail', async () => {
    (sendEmail as jest.Mock).mockRejectedValueOnce(new Error('SMTP down'));

    await expect(notifyUser('user-1', 'hello')).rejects.toThrow('SMTP down');
  });
});

여기서 afterEachjest.clearAllMocks()를 넣는 이유는 테스트 간에 mock의 호출 기록이 누적되지 않도록 하기 위해서입니다. 이걸 빠뜨리면 앞 테스트에서 호출된 기록이 다음 테스트의 toHaveBeenCalledWith 검증에 섞여 들어가 원인을 파악하기 어려운 실패가 발생합니다.

모듈 전체를 mock하지 않고 일부 함수만 덮어써야 하는 부분 모킹이 필요할 때는 jest.requireActual로 실제 모듈을 먼저 불러온 다음 필요한 export만 덮어쓰는 방식이 유지보수에 유리합니다.

jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  generateId: jest.fn(() => 'fixed-id-for-test'),
}));

이렇게 하면 generateId만 테스트용으로 고정된 값을 반환하고, 나머지 유틸 함수는 실제 구현 그대로 동작합니다. 모듈 전체를 mock하고 필요한 함수를 하나씩 다시 채워 넣는 것보다 훨씬 안전합니다.

React Testing Library로 컴포넌트 테스트하기

React 컴포넌트를 테스트할 때는 “사용자가 화면에서 보는 것” 기준으로 검증하는 것이 핵심입니다. 컴포넌트 내부의 state를 직접 조작하거나 확인하지 말고, 버튼을 클릭했을 때 화면의 텍스트가 실제로 바뀌는지를 검증해야 합니다. 이렇게 작성한 테스트는 내부 구현이 바뀌어도 사용자 관점의 동작이 유지되는 한 계속 통과하므로, 리팩토링에 대한 저항력이 훨씬 강합니다.

npm install -D @testing-library/react @testing-library/jest-dom
// src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';

describe('Counter', () => {
  test('should render initial count', () => {
    render(<Counter />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  test('should increment count', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('Increment'));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  test('should not go below zero after multiple decrements', () => {
    render(<Counter />);
    const decrementButton = screen.getByText('Decrement');
    fireEvent.click(decrementButton);
    fireEvent.click(decrementButton);
    expect(screen.getByText('Count: -2')).toBeInTheDocument();
  });
});

세 번째 테스트는 일부러 경계 조건을 다루고 있습니다. 실제로 이 컴포넌트는 0 이하로 내려가는 것을 막고 있지 않으므로, 이 테스트는 통과하지만 “이게 정말 의도한 동작인가?”라는 질문을 남깁니다. 테스트를 작성하는 과정에서 이런 질문이 떠오른다면, 그 자체가 테스트의 가치입니다. 버그를 미리 발견한 것이거나, 최소한 요구사항을 다시 확인해야 한다는 신호입니다.

스냅샷 테스트는 신중하게 사용한다

스냅샷 테스트는 UI의 의도치 않은 변경을 잡아내는 데 유용하지만, 남발하면 스냅샷 diff를 매번 확인 없이 승인하는 습관이 생기면서 오히려 버그를 놓치는 역효과가 생깁니다. 마크업이 자주 바뀌지 않는, 정말 회귀를 막고 싶은 컴포넌트에만 제한적으로 쓰는 것을 추천합니다.

export function UserCard({ name, email }: { name: string; email: string }) {
  return (
    <div className="user-card">
      <h2>{name}</h2>
      <p>{email}</p>
    </div>
  );
}
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';

test('should match snapshot', () => {
  const { container } = render(
    <UserCard name="John" email="john@example.com" />
  );
  expect(container).toMatchSnapshot();
});

스냅샷 파일은 __snapshots__ 디렉터리에 자동으로 생성되며, 이 파일도 반드시 버전 관리에 포함시켜야 합니다. 팀원이 PR 리뷰를 할 때 스냅샷 diff가 보이면, 그 변경이 의도한 것인지 반드시 확인하는 절차를 두는 것이 중요합니다. jest --ci 모드에서는 새로운 스냅샷을 자동으로 생성하지 않고 실패로 처리되므로, CI에서 예상치 못한 스냅샷 누락을 바로 잡아낼 수 있습니다.

커버리지: 숫자보다 질문이 먼저다

커버리지를 측정하려면 npm run test:coverage를 실행하면 됩니다. 임계값(threshold)을 걸어두는 것은 팀 안에서 합의가 있을 때만 의미가 있습니다.

module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,ts,tsx}',
    '!src/**/*.test.{js,ts,tsx}',
    '!src/index.tsx',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

“맹목적으로 80%를 맞추자”보다 “이 임계값이 실제로 우리 제품을 지켜주는가?”를 먼저 물어보는 것이 낫습니다. 다시 한번 강조하지만, 100% 커버리지 자체는 의미가 없습니다. 엣지 케이스를 검증하지 않는 형식적인 테스트로 숫자만 채우는 경우가 실무에서 생각보다 많이 발생하기 때문입니다. 커버리지는 목표가 아니라, 테스트가 비어 있는 위험한 영역을 찾기 위한 도구로 쓰는 것이 바람직합니다.

Fake Timers로 시간 의존 코드 다루기

setTimeout이나 setInterval에 의존하는 코드를 실제 시간이 흐르는 대로 기다리며 테스트하면 테스트 속도가 느려지고, 타이밍에 따라 결과가 달라지는 불안정한 테스트가 됩니다. Fake Timers를 사용하면 시간을 원하는 대로 앞으로 돌릴 수 있어 이 문제를 해결할 수 있습니다.

import { jest } from '@jest/globals';

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

it('setTimeout 콜백이 지정한 시간 뒤에 실행된다', () => {
  const fn = jest.fn();
  setTimeout(fn, 300);

  expect(fn).not.toHaveBeenCalled();

  jest.advanceTimersByTime(300);

  expect(fn).toHaveBeenCalledTimes(1);
});

여기서 afterEachjest.useRealTimers()를 반드시 넣어야 하는 이유가 있습니다. Fake Timers를 켠 채로 다음 테스트로 넘어가면, 그 테스트가 의존하는 실제 타이머 동작(예: 라이브러리 내부의 debounce)까지 영향을 받아 예상치 못한 실패가 발생할 수 있습니다. Fake Timers는 반드시 켠 자리에서 끄는 것을 원칙으로 삼아야 합니다.

MSW로 네트워크 경계를 결정적으로 만들기

fetch나 axios 호출을 개별적으로 mock하는 방식은 클라이언트 라이브러리를 바꾸거나 호출 방식이 조금만 달라져도 테스트를 전부 고쳐야 하는 부담이 있습니다. MSW(Mock Service Worker)는 네트워크 계층 자체에서 요청을 가로채기 때문에, 실제 fetch 코드를 건드리지 않고도 응답을 고정할 수 있습니다.

// mocks/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: Number(params.id), name: 'Test User' });
  })
);
// jest.setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

이 방식의 가장 큰 장점은 “가짜 클라이언트”를 별도로 하나 더 유지보수하지 않아도 된다는 점입니다. 테스트가 실제 HTTP 요청 형식(URL, 메서드, 쿼리 파라미터)까지 검증하게 되므로, fetch 함수 자체를 mock했을 때보다 실제 프로덕션 동작과 더 가까운 신뢰도를 얻을 수 있습니다.

트러블슈팅: 종료되지 않는 Jest 프로세스

테스트는 전부 통과했는데 Jest 프로세스가 끝나지 않고 “Jest did not exit one second after the test run has completed” 같은 워닝을 남기는 경우가 있습니다. 이럴 때는 --detectOpenHandles 옵션으로 실행해 보면 어떤 리소스가 정리되지 않았는지 추적할 수 있습니다.

npx jest --detectOpenHandles --runInBand

흔한 원인은 다음과 같습니다.

  • setInterval이나 setTimeout을 정리하지 않고 남겨둔 경우
  • 테스트에서 열어둔 DB 커넥션이나 서버 소켓을 닫지 않은 경우
  • Fake Timers를 켠 뒤 useRealTimers로 복구하지 않은 경우

근본적인 해결책은 afterEachafterAll에서 테스트가 생성한 리소스를 확실히 정리하는 습관을 들이는 것입니다. 특히 여러 테스트 파일에서 같은 서버나 커넥션 풀을 공유한다면, 정리 순서가 어긋나는 순간 이런 문제가 재발하기 쉽습니다.

Jest 대 Vitest, 그리고 E2E는 다른 도구로

E2E 테스트는 보통 Jest로 직접 하지 않고 Playwright나 Cypress로 처리합니다. Jest는 유닛·통합 테스트 영역에 집중하고, 브라우저를 실제로 띄워 사용자 흐름을 검증하는 영역은 전용 도구에 맡기는 역할 분담이 일반적입니다. 관련 내용은 Playwright E2E 테스팅 가이드Cypress E2E 테스팅 가이드에서 다룹니다. 컴포넌트 테스트 작성 방식을 더 깊이 다루고 싶다면 Testing Library 완벽 가이드도 참고할 수 있습니다.

Vitest가 더 잘 맞는 팀도 많고, 레거시 프로젝트라면 여전히 Jest에 남아 있는 경우도 많습니다. 새 프로젝트를 시작하는 게 아니라면 “지금 이 저장소가 이미 무엇을 쓰고 있는가”를 1순위로 두고, 그다음에 실행 속도나 ESM 지원 같은 차이를 비교하는 순서를 권장합니다. 두 도구 모두 API가 상당히 비슷하기 때문에, 이미 Jest로 작성한 테스트가 많다면 전면 교체보다 점진적인 마이그레이션이 현실적인 경우가 많습니다.

영문으로 더 자세히 정리한 버전은 Jest Testing Guide (English)에서 확인할 수 있습니다. 배포 전에는 git addgit commitgit push 순서를 지킨 뒤 npm run deploy를 실행하는 것만 기억해 두면 됩니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.