본문으로 건너뛰기
Previous
Next
Vitest 완전 가이드 | Jest보다 10배 빠른 Vite 네이티브 테스트 프레임워크

Vitest 완전 가이드 | Jest보다 10배 빠른 Vite 네이티브 테스트 프레임워크

Vitest 완전 가이드 | Jest보다 10배 빠른 Vite 네이티브 테스트 프레임워크

이 글의 핵심

Jest를 대체하는 차세대 테스트 프레임워크 Vitest. Vite와 완벽히 통합되어 Jest보다 10배 빠르며, ESM 네이티브 지원, Watch 모드에서 즉각적인 피드백을 제공합니다. Jest API와 호환되어 마이그레이션이 쉽습니다.

이 글의 핵심

Vitest는 Jest 대비 매우 빠른 피드백 루프를 제공하는 Vite 네이티브 테스트 러너입니다. ESM(ES 모듈)을 전제로 설계되었고, Vite의 변환·해석·캐시와 동일한 경로로 소스를 로드하므로 “앱에서 보는 것과 테스트에서 보는 것”의 괴리를 줄이기 쉽습니다. Jest와 유사한 API 덕분에 팀이 이미 Jest/RTL(React Testing Library) 패턴에 익숙하다면 학습 비용이 낮은 편입니다. 다만 “항상 10배” 같은 숫자는 환경·캐시·테스트 수에 따라 달라지므로, 아래에서 성능·설정·마이그레이션을 균형 있게 짚겠습니다.

목차

  • Vitest vs Jest: 성능, ESM, 설정
  • Vite 통합의 장점과 실전 vitest.config
  • 유닛·통합·E2E 테스트 전략
  • Mocking 심화: vi.mock, vi.spyOn, 모듈 경계
  • Coverage 설정과 팀에서의 활용
  • Snapshot 실무 팁
  • 비동기 테스트 패턴
  • CI/CD에서의 캐시·병렬·리포트
  • 실제 프로젝트 마이그레이션에 가까운 절차

Vitest vs Jest: 성능, ESM, 설정

성능(체감과 측정)

Vitest는 Vite의 변환 파이라인(예: esbuild 기반의 빠른 변환)을 그대로 타고, Watch 모드에서 변경된 모듈 그래프만 다시 돌리기 쉬운 구조에 가깝습니다. Jest는 생태계가 크고 안정적이지만, 대규모 모노레포 + TypeScript + ESM 혼용에서 설정이 커질수록 “기동 시간”과 “재실행 지연”이 누적되기 쉽습니다. 저는 사내 Vite+React 앱에서 Jest(바벨/sw-jest)와 Vitest를 번갈아 쓰며, 초기 1회 전체 스위트는 프로젝트에 따라 2~3배에서 그 이상까지 차이가 나는 경우를 본 반면, Watch 중 소규모 파일 수정은 Vitest 쪽이 체감상 확실히 유리한 편이었습니다(단, I/O·네트워크·거대한 스냅샷 누적이 병목이면 둘 다 느려집니다).

측정 팁(형식적 감이 아닌 근거 확보):

  • 동일 머신, 동일 Node 버전, CI=1/watch off콜드/웜 각각 3회 평균
  • vitest --reporter=verbose와 Jest의 timing 출력(팀이 쓰는 리포터)로 최댓값이 튀는 테스트를 먼저 잡기

ESM 지원

Jest 29 이후 ESM story가 개선됐지만, 레거시 CJS 믹스, package.jsonexports/imports, tsconfigmoduleResolution 조합에 따라 “테스트에서만” 깨지는 케이스가 생깁니다. Vitest는 Vite의 모듈 해석을 쓰므로 앱 번들이 ESM이면 테스트도 같은 길을 당기기 쉬워, 경계 조건(특히 서드파티 ESM-only 패키지)에서 마찰이 줄어드는 사례가 많습니다. 반면 CJS-only 유틸이나 Jest에 최적화된 훅(구형 프리셋)을 쓰는 팀은 Jest가 여전히 편할 수 있습니다.

설정(중복을 줄이는 것이 핵심)

Vitest는 vitest.config.ts에서 Vite plugins/resolve/define을 공유할 수 있어, “앱 alias가 테스트에서 먹지 않는다” 류의 이슈를 줄입니다. Jest는 moduleNameMapper, transform, testEnvironment테스트 전용 맵이 길어지기 쉬워 유지보수 비용이 누적됩니다. 아래는 vite.config를 합쳐 쓰는 흔한 형태입니다.

// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: 'jsdom',
      include: ['src/**/*.{test,spec}.{ts,tsx}'],
      setupFiles: ['./src/test/setup.ts'],
      // 대규모 모노레포면 pool/watcher 튜닝이 체감에 큰 영향
      pool: 'threads',
    },
  })
);

Vite 통합의 장점: resolve·define·CSS를 한 번에

Vite는 개발 환경에서 import.meta.env·?raw·CSS import 등 번들러 관점의 의미를 갖습니다. Vitest는 동일한 플러그인을 쓰므로, “테스트에서 CSS를 무시”하거나 가짜 env를 주입하는 흐름이 단순해질 수 있습니다(프로젝트 정책에 맞게 선택).

예: import.meta.env에 의존하는 모듈을 통합 수준에서 검증

// src/config.ts
export function apiBase() {
  return import.meta.env.VITE_API_BASE;
}
// src/config.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { apiBase } from './config';

describe('config', () => {
  const original = { ...import.meta.env };

  beforeEach(() => {
    vi.resetModules();
    // 주의: import.meta env 조작은 프로젝트/번들 전제에 민감합니다.
    (import.meta.env as { VITE_API_BASE?: string }).VITE_API_BASE = 'https://api.test';
  });

  afterEach(() => {
    Object.assign(import.meta.env, original);
  });

  it('reads VITE_API_BASE', async () => {
    const { apiBase: fresh } = await import('./config');
    expect(fresh()).toBe('https://api.test');
  });
});

실제로는 Vite define으로 고정하거나, 테스트에서 작은 래퍼(ports & adapters) 를 두는 편이 더 안정적일 때가 많습니다(환경 흡수 계층을 두면 E2E/통합과 메시징이 맞춰짐).

CSS import가 있는 컴포넌트test.css: true로 스타일을 “처리”하거나(시간이 듦), setup에서 vi.stubGlobal/CSS stub로 비용을 줄이는 팀이 많습니다(정책만 일관되면 됨).

유닛·통합·E2E 테스트 전략

계층을 나누는 기준(실무에서 흔한 합의)

  • 유닾: 순수 함수, 리듀서, 작은 훅(부작용이 적은), 유틸·검증 로직. I/O·라우터·스토리지를 직접 때리지 않는 것이 이상적입니다.
  • 통합(컴포넌트+Fetcher mock / 작은 MSW): React/Vue에서 렌더 트리 + 사용자 시나리오를 검증. 네트워크는 fetch mock 또는 MSW로 “HTTP 계약”을 흉내 냅니다.
  • E2E(Playwright/Cypress 등): 진짜 배포 아티팩트에 가까운 경로(브라우저, 실제 API 스테이징)에서 흐름을 검증. 느리므로 “핵심 사용자 경로”만 남깁니다.

Vitest는 보통 1~2에 집중하고, E2E는 별 러너로 분리하는 구성이 가장 흔합니다(실패를 빠르게, 신뢰는 E2E로).

describe로 의도를 드러내기(통합 테스트 예시 틀)

// src/features/cart/cart.int.test.tsx (파일 suffix 규칙은 팀 컨벤션으로)
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Cart } from './Cart';

describe('Cart integration', () => {
  beforeEach(() => {
    // MSW server를 켠다면 여기서 resetHandlers()
  });

  it('사용자가 수량을 바꾸면 합계가 갱신된다', async () => {
    const user = userEvent.setup();
    render(<Cart />);
    // arrange/act/assert: 통합은 “행동→결과”가 한 덩어리로 읽히게
    await user.click(screen.getByRole('button', { name: '증가' }));
    expect(screen.getByText(/합계/)).toBeInTheDocument();
  });
});

Mocking 심화: vi.mockvi.spyOn

vi.mock으로 모듈 전체를 대체(경계)

vi.mock호이스팅에 민감합니다. “파일 상단에 선언, 팩토리는 가능하면 단순하게”가 안전한 패턴입니다. 테스트 대상의존성 모듈을 분리해 두면 vi.mock이 가장 깔끔합니다(의존성만 가짜로 바꾸고, SUT는 실제 구현).

// src/lib/transport.ts
export async function postJson(url: string, body: unknown) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(String(res.status));
}
// src/services/createUser.ts
import { postJson } from '../lib/transport';

export async function createUser(name: string) {
  await postJson('/api/users', { name });
  return { ok: true as const };
}
// src/services/createUser.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser } from './createUser';
import { postJson } from '../lib/transport';

vi.mock('../lib/transport', () => ({
  postJson: vi.fn().mockResolvedValue(undefined),
}));

describe('createUser', () => {
  beforeEach(() => {
    vi.mocked(postJson).mockClear();
  });

  it('사용자 생성 시 transport를 호출한다', async () => {
    await createUser('alice');
    expect(postJson).toHaveBeenCalledWith('/api/users', { name: 'alice' });
  });
});

importOriginal로 “일부만 목”을 끼우는 vi.mock + importOriginal 패턴도 쓰지만, 팀이 ESM/호이스팅에 익숙하지 않다면 의존성 파일을 잘게 쪼개는 편이 리뷰 비용이 적습니다.

실무 팁: “모듈을 통째로 갈아끼우기”“원본에 spy만 얹기” 중에서, 후자가 가능하면 vi.spyOn이 더 읽기 쉬운 경우가 많습니다(대상이 export된 객체/함수일 때).

vi.spyOn으로 최소 침투

// src/lib/logger.ts
export const logger = {
  error: (m: string) => console.error(m),
};

// some-module.ts
import { logger } from './logger';
export function dangerous() {
  try {
    // ...
  } catch (e) {
    logger.error('failed');
  }
}
import { describe, it, expect, vi, afterEach } from 'vitest';
import * as logger from './lib/logger';
import { dangerous } from './some-module';

describe('dangerous', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('에러를 로깅한다', () => {
    const spy = vi.spyOn(logger.logger, 'error').mockImplementation(() => {});
    dangerous();
    expect(spy).toHaveBeenCalled();
  });
});

Timer·시간: vi.useFakeTimers와 함께

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  afterEach(() => {
    vi.useRealTimers();
  });

  it('지연 후 한 번만 실행', async () => {
    const fn = vi.fn();
    // debounce(fn, 200) 같은 가정
    // vi.advanceTimersByTime(200) 등으로 진행
    expect(fn).toHaveBeenCalledTimes(0);
  });
});

Coverage: 설정과 팀에서의 활용

최소 설치(보통 v8 + @vitest/coverage-v8)

npm i -D @vitest/coverage-v8

vitest.config.ts 예시(임계치로 회귀 방지):

// vitest.config.ts (일부)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json-summary', 'lcov'],
      // PR에서 diff만 보는 팀이 많음: "전체 90%"는 목표가 아니라 신호
      thresholds: {
        lines: 70,
        functions: 70,
        branches: 60,
        statements: 70,
      },
      exclude: [
        '**/*.d.ts',
        '**/*.config.*',
        'src/test/**',
        '**/__mocks__/**',
      ],
    },
  },
});

팀에서 커버리지를 “잘” 쓰는 법(경험담): 숫자를 올리는 것보다, 핵심 경로(결제, 권한, 가격 계산)에 민감한 파일에 임계치를 두고, PR에서 json-summary를 아티팩트로 올려 main 대비 감소만 막는 쪽이 분쟁이 적습니다. 저는 “전사 80%” 같은 단일 지표는 오히려 의미 없는 테스트 양산을 부추겼다가, 중요 모듈 단위의 최소 기준으로 바꾼 뒤 품질 대화가 정상화된 경험이 있습니다.

Snapshot 실무 팁(스냅샷은 “계약”이다)

  • UI 전체 HTML 스냅샷은 취약합니다. className 변화, i18n, 날짜에 연약합니다. 가능하면 toMatchInlineSnapshot짧고 의미 있는 조각으로 제한하세요.
  • 스냅샷 리뷰는 PR diff에서 반드시 읽혀야 합니다. “스냅샷만 5,000줄”은 리뷰가 불가능해집니다.
  • 접근성/역할 기반 assertion(RTL)과 섞는 편이 장기 유지에 유리한 경우가 많습니다.

실무 패턴(작은 단위):

// ...
expect(result).toMatchInlineSnapshot(`"OK:2"`);

또는 서버 응답 shape만 스냅샷:

expect(payload.user).toMatchObject({
  id: expect.any(String),
  email: expect.stringMatching(/@/),
});

비동기 테스트 패턴: async/await, expect.rejects, race

원칙: “비동기 테스트에서의 실패”는 미처리 Promise로 남는 경우가 많으므로, 항상 await하거나 return expect(...).rejects 형태로 명시적으로 소비합니다.

import { describe, it, expect } from 'vitest';
import { fetchJson } from './api';

describe('async', () => {
  it('정상 JSON', async () => {
    await expect(fetchJson('/ok')).resolves.toEqual({ a: 1 });
  });

  it('에러', async () => {
    await expect(fetchJson('/fail')).rejects.toThrow(/network/i);
  });
});

레이스/타임아웃을 다룰 때는 AbortSignal, vi.waitFor(프로젝트에서 쓰는 helper), 또는 “상태가 될 때까지”를 명시하는 편이 안전합니다(암묵 setTimeout(0) 남용은 피하기).

CI/CD 통합(경험담: 빠른 실패, 캐시, flakiness)

  • 캐시 키: package-lock/pnpm-lock + Node 버전 + OS. Vitest/TS에서 이득이 큽니다.
  • sharding(분할): vitest --shard=1/4 류로 병렬 잡(큰 모노레포에서 흔합니다).
  • 리포트: junit/github-actions 애너테이션을 붙이면 실패 테스트를 PR에 바로 붙일 수 있어, 리뷰어가 “어디가 빨갛냐”를 빨리 봅니다.
  • 플라키(불안정) 테스트는 E2E뿐 아니라, 타이머/전역 Date/네트워크 mock에서도 납니다. CI에서만 터지면 병렬 충돌(전역 상태) 를 의심하세요(저는 localStorage stub 충돌로 주말에 한 번 고생한 적이 있습니다).

GitHub Actions 흐름(개념):

# 개념 예시: 실제 YAML은 팀의 러너/OS에 맞게 조정
- run: npm ci
- run: npx vitest run --reporter=default --reporter=junit --outputFile=test-results.xml

실제에 가까운 마이그레이션 절차(케이스 스터디 톤)

배경(가상 사례에 가깝게 서술): Vite+React+TS로 전환한 뒤, Jest는 ts-jest·moduleNameMapper·jsdom 설정이 200줄을 넘었고, 스펙을 추가할수록 watch가 버거워졌습니다. Vitest는 Vite resolve.alias를 그대로 가져가 “테스트 전용 alias”를 제거하는 데 기여했습니다.

권장 순서(저는 이 순서로 팀 PR을 쪼갰습니다):

  1. vitest + @vitest/coverage-v8 + setup만으로 최소 스모크 1~2개를 통과시킨다(환경/alias 검증).
  2. import { describe, it, expect, vi, beforeEach } from 'vitest'치환(자동화 가능), Jest의 jestvi로 매핑.
  3. jest.mockvi.mock 패턴을 점검(호이스팅/동적 import 이슈).
  4. jest.spyOnvi.spyOn, jest.useFakeTimersvi.useFakeTimers.
  5. 스냅샷/커버리지 임계치를 한 단계씩 올리며, 실패는 최상위 몇 개 파일의 의존성 이슈에서 오는 경우가 많으니 거기서 멈춰 원인을 나눕니다.

주의(실제로 자주 터짐):

  • global/window 전역 돌연변이가 테스트 간 누수
  • CJS/ESM 이중 import
  • fetch mock의 Response 흉내 불완전(json() 등)

Vitest vs Jest: 한눈에

측면VitestJest
성능(일반적)Vite 파이프라인·캐시·Watch에 유리한 편안정·성숙, 대규모에서 설정 비용·기동이 커질 수 있음
ESMVite와 동일한 해석에 가깝게 맞추기 쉬움가능하지만 설정/프리셋에 따라 팀별 편차 큼
생태/도구빠르게 발전, Vite권이 강함툴·레시피·질문 스택이 방대
APIJest와 매우 닮음사실상 표준 역할
E2E본질은 별 러너(Playwright 등)동일

Watch 모드와 UI

npm test          # watch
npx vitest run    # CI용 1회 실행
npm run test:ui   # UI로 실패/필터링(팀 온보딩에 유용)

Watch는 “내가 방금 만진 테스트만” 빠르게 돌릴 수 있을 때 효과가 가장 큽니다(의존성 그래프/필터 전략을 팀 규칙으로).

React 컴포넌트(복습)와 setup

@testing-library/reactcleanupReact 18+ 자동 루트에 따라 필요 없을 수도 있지만(프로젝트 문서/버전에 따름), 전역/포털/라우터 잔상 이슈가 있으면 afterEach에서 정리하는 팀이 많습니다(일관성이 중요).

Mock 함수(기본)와 fetch 흉내

Jest에 익숙하다면 vi.fn()·toHaveBeenCalled 패턴은 거의 그대로입니다. fetch mock은 Response/Headers 흉내를 정확히 맞추는지 확인하세요(반만 맞으면 브라우저와 다르게 통과하는 테스트가 됩니다).

핵심 정리

  1. Vitest는 Vite 앱에 특히 잘 맞는 테스트 러너이며, Jest API 친화로 이동 비용이 낮은 편입니다.
  2. 성능은 “항상 N배”가 아니라, 캐시/테스트 수/병목에 달렸고, CI에서는 캐시+shard+명확한 리포트가 체감에 큰 영향을 줍니다.
  3. Mockingvi.mock(경계)과 vi.spyOn(최소 침투)의 역할을 나누는 것이 유지보수에 유리합니다.
  4. 스냅샷/커버리지는 “숫자 경쟁”보다 핵심 경로에 집중하는 편이 장기적으로 건강합니다.

본 가이드는 개인/팀의 경험을 바탕으로 한 사례 중심 설명을 포함합니다. Node·Vitest·Vite 버전에 따라 API 동작(특히 ESM/환경 변수)이 달라질 수 있으니, Vitest 공식 문서와 릴리스 노트를 함께 확인하는 것이 안전합니다.