본문으로 건너뛰기
Previous
Next
Jest 완벽 가이드 | 유닛 테스트·Mock·Snapshot·Coverage·React Testing

Jest 완벽 가이드 | 유닛 테스트·Mock·Snapshot·Coverage·React Testing

Jest 완벽 가이드 | 유닛 테스트·Mock·Snapshot·Coverage·React Testing

이 글의 핵심

Jest는 haste-map·worker·환경(jsdom/node)으로 테스트를 병렬 실행하고, jest.mock 호이스팅·모듈 레지스트리로 의존성을 격리합니다. Fake Timers·MSW·openHandles 진단까지 포함해 프로덕션 CI에서 안정적으로 돌리는 방법을 정리합니다.

옛날 팀에서 겪은 일인데요, 월요일 아침마다 같은 테스트가 가끔 빨간불이었어요. 로컬에서는 통과하는데 CI에서만 터지거나, 반대로 CI는 초록인데 누군가 노트북에서만 깨지고. 원인 찾느라 커피 세 잔은 기본이었죠. 결국 뜯어보니 Date.now()를 그대로 믿고 있었고, 테스트끼리 전역 싱글톤 모듈 상태를 공유하고 있었어요. 병렬 워커가 파일 순서 바꿔 돌리니 레이스가 터진 거고요. 그때 fake timer로 시간을 고정하고, MSW로 HTTP 응답을 결정적으로 만들고, --runInBand로 일단 재현부터 맞추는 삼단 콤보로 잡았습니다. 플래키 테스트는 “운 나쁜 CI” 문제가 아니라 거의 항상 설계가 틀린 거예요.

Jest 얘기로 들어가 볼게요. 그냥 JavaScript/TypeScript용 테스트 러너인데, 설정 거의 없이 돌아가고(haste-map으로 파일 추적, worker로 병렬 돌림), jest.mock이 호이스팅된다는 점만 머리에 넣으면 반은 먹고 들어가요. 100% 커버리지는 의미 없어요 — 숫자 맞추려고 의미 없는 assert만 늘리는 건 시간 낭비고, 중요한 분기·도메인 규칙이 실제로 검증되는지가 전부입니다. 커버리지는 “이 코드가 한 번이라도 실행됐다”는 기록일 뿐이에요.

설치는 흔히 이렇게요.

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}',
  ],
};

가장 기본은 그냥 함수 몇 개 두드려 보는 거예요.

// 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);
  });
});

Matchers는 문서 찾아보면 되고, toBe / toEqual / toThrow 정도만 손에 익혀도 대부분 커버돼요.

비동기는 async/await가 제일 덜 헷갈려요. Promise를 return 안 하고 넘기면 가끔 통과하는데 사실 실패해야 하는 그런 지옥 패턴도 같이 사라져요.

async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
test('fetchUser should return user', async () => {
  const user = await fetchUser(1);
  expect(user).toHaveProperty('name');
  expect(user.id).toBe(1);
});

Mock은 jest.fn()으로 콜백 흉내 내고, 모듈 단위로는 jest.mock('./api') 같은 걸 쓰죠. 다만 mock이 import보다 먼저 올라간다는 걸 잊으면 “왜 내 mock이 안 먹지?” 하루 종일 보내게 돼요. 부분 목이 필요하면 팩토리 안에서 jest.requireActual로 진짜 모듈 펼친 다음 필요한 것만 덮어쓰는 쪽이 정신 건강에 좋아요.

React Testing Library는 “사용자가 보는 것” 기준으로 찍는 게 포인트예요. 내부 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 />);
    const incrementButton = screen.getByText('Increment');
    fireEvent.click(incrementButton);
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

스냅샷은 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="[email protected]" />
  );
  expect(container).toMatchSnapshot();
});

커버리지 돌리려면 npm run test:coverage고, threshold 걸어두는 건 팀 합의 있을 때만이에요. 맹목적으로 80% 맞추기보다 “이 임계값이 우리 제품을 지키는가?”를 먼저 물어보는 게 낫습니다. 100% 커버리지는 의미 없어요, 한 번 더 말할게요 — 엣지 케이스를 안 쓰는 가짜 테스트로만 채우는 경우가 너무 많거든요.

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,
    },
  },
};

타이머를 켠 채로 실제 네트워크·DB까지 같이 돌리면 느려지고 꼬이기 쉬워요. 단위랑 통합은 파일이나 설정으로 쪼개는 편이 마음 편해요.

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);
});

MSW 쓰면 fetch/axios 전부 갈아엎지 않고도 네트워크 경계에서 응답을 고정할 수 있어요. “가짜 클라이언트” 하나 더 만드는 것보다 유지보수가 훨씬 낫습니다. Jest가 안 끝나고 워닝만 뜨면 --detectOpenHandles로 타이머나 열린 소켓 찾아보고, afterEach에서 정리 습관 들이면 됩니다.

E2E는 Jest로 안 하고 Playwright나 Cypress로 가는 게 보통이에요. 영문으로 더 길게 정리한 버전은 [Jest Testing Guide (English)](/en/blog/jest-testing-guide/에 있어요. 배포 전에는 git addgit commitgit push 하고 npm run deploy 순서 맞추는 것 정도만 기억해 두면 됩니다.

Vitest가 더 잘 맞는 팀도 많고, 레거시는 Jest에 남아 있는 경우도 많아요. 제 취향으론 “지금 레포가 뭘 쓰고 있냐”가 1순위고, 그다음에 속도·ESM 이야기 하는 편이에요.