Vitest
프론트엔드 프로젝트에서 테스트를 작성하려면 테스트 러너가 필요하다. 오랫동안 Jest가 사실상의 표준이었는데, Vite 기반 프로젝트에서 Jest를 쓰려면 꽤 번거로운 설정이 필요하다. Vite는 ESM과 esbuild를 사용하는데, Jest는 기본적으로 CommonJS 기반이라 변환 과정에서 충돌이 생기기 때문이다. ts-jest나 @swc/jest 같은 트랜스포머를 별도로 설정해야 하고, path alias도 moduleNameMapper로 일일이 매핑해야 한다.
Vitest는 이 문제를 해결하기 위해 만들어진 Vite 네이티브 테스트 러너다. Vite의 설정(alias, plugin, transform 파이프라인)을 그대로 재사용하기 때문에 별도의 변환 설정이 필요 없다. vite.config.ts에서 설정한 path alias가 테스트에서도 동작하고, JSX/TSX 변환도 Vite가 이미 처리하므로 추가 설정이 불필요하다.
Jest와의 호환성
Vitest는 Jest와 거의 동일한 API를 제공한다. describe, it, expect, beforeEach, afterEach 등의 함수가 같은 이름, 같은 시그니처로 존재한다. Jest에서 마이그레이션할 때 대부분의 테스트 코드를 그대로 사용할 수 있다.
import { describe, it, expect, vi } from 'vitest';
describe('Calculator', () => {
it('should add two numbers', () => {
expect(1 + 2).toBe(3);
});
it('should call callback after calculation', () => {
const callback = vi.fn();
callback(42);
expect(callback).toHaveBeenCalledWith(42);
});
});
Jest의 jest.fn()은 vi.fn()으로, jest.mock()은 vi.mock()으로 대응된다. vi는 Vitest의 유틸리티 객체로, Jest의 jest 글로벌 객체와 같은 역할을 한다.
주요 차이점은 모킹 호이스팅 방식이다. Jest에서 jest.mock()은 자동으로 파일 최상단으로 호이스팅되지만, Vitest에서 vi.mock()도 호이스팅되긴 하되 ESM 환경에서 동작하기 때문에 동적 모킹 시 vi.hoisted()를 사용해야 하는 경우가 있다.
// vi.hoisted()로 모킹 팩토리에서 사용할 변수를 호이스팅
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn(),
}));
vi.mock('./api', () => ({
fetchData: mockFetch,
}));
설정
Vitest 설정은 vitest.config.ts를 별도로 만들거나, 기존 vite.config.ts에 test 필드를 추가해서 작성한다. 별도 파일로 분리하면 빌드 설정과 테스트 설정을 독립적으로 관리할 수 있다.
import { defineConfig } from 'vitest/config';
import * as path from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/tests/setup.ts'],
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reportsDirectory: './coverage',
include: ['src/components/**/*.{ts,tsx}', 'src/hooks/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.spec.{ts,tsx}'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
environment
테스트가 실행되는 환경을 지정한다. DOM을 다루는 프론트엔드 테스트에서는 jsdom이나 happy-dom을 사용한다.
node(기본값): Node.js 환경. 유틸 함수나 서버 로직 테스트에 적합.jsdom:window,document,localStorage등 브라우저 API를 시뮬레이션. React 컴포넌트 테스트의 기본 선택지.happy-dom: jsdom의 경량 대안. 속도가 더 빠르지만 일부 API가 구현되지 않았을 수 있다.
// 파일 단위로 환경을 오버라이드할 수도 있다
// @vitest-environment node
파일 최상단에 주석으로 환경을 지정하면 해당 파일에서만 다른 환경을 사용할 수 있다. API 유틸리티 테스트는 node, 컴포넌트 테스트는 jsdom처럼 혼합 사용이 가능하다.
globals
true로 설정하면 describe, it, expect 등을 import 없이 사용할 수 있다. Jest에서 마이그레이션할 때 편리하다. 다만 TypeScript에서 타입 인식을 위해 tsconfig.json에 타입 선언을 추가해야 한다.
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
setupFiles
모든 테스트 파일이 실행되기 전에 먼저 실행할 파일을 지정한다. 글로벌 모킹, 테스트 유틸리티 등록, 폴리필 설정 등에 사용한다.
// src/tests/setup.ts
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// 브라우저 API 모킹
window.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// 공통 모듈 모킹
vi.mock('./components/ui/Dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
setup 파일에서 @testing-library/jest-dom을 import하면 toBeInTheDocument(), toHaveTextContent() 같은 DOM 전용 matcher를 모든 테스트에서 사용할 수 있다. 또한 IntersectionObserver처럼 jsdom에 존재하지 않는 브라우저 API를 여기서 모킹해두면 모든 테스트에서 동작한다.
coverage
코드 커버리지 설정이다. Vitest는 v8과 istanbul 두 가지 커버리지 프로바이더를 지원한다.
- v8: V8 엔진의 내장 커버리지를 사용. 빠르지만 일부 엣지 케이스에서 정확도가 떨어질 수 있다.
- istanbul: 코드를 계측(instrument)해서 커버리지를 측정. 더 정확하지만 속도가 느리다.
# 커버리지 리포트 생성
npx vitest run --coverage
include와 exclude로 커버리지를 측정할 파일 범위를 지정한다. 테스트 파일 자체나 타입 정의 파일은 보통 제외한다.
모킹
Vitest의 모킹 시스템은 Jest와 매우 유사하지만, ESM 환경에서 더 자연스럽게 동작한다.
vi.fn() - 함수 모킹
const mockHandler = vi.fn();
// 반환값 설정
mockHandler.mockReturnValue(42);
mockHandler.mockReturnValueOnce(100); // 한 번만
// 비동기 반환값
mockHandler.mockResolvedValue({ data: 'test' });
mockHandler.mockRejectedValue(new Error('fail'));
// 구현 대체
mockHandler.mockImplementation((x: number) => x * 2);
// 호출 확인
expect(mockHandler).toHaveBeenCalled();
expect(mockHandler).toHaveBeenCalledTimes(3);
expect(mockHandler).toHaveBeenCalledWith('arg1', 'arg2');
vi.mock() - 모듈 모킹
모듈 전체를 모킹한다. 팩토리 함수를 제공하면 해당 모듈의 export를 대체할 수 있다.
// 모듈 전체 모킹
vi.mock('./api/users', () => ({
getUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]),
createUser: vi.fn().mockResolvedValue({ id: 3, name: 'Charlie' }),
}));
팩토리 함수 없이 vi.mock('./module')만 호출하면 해당 모듈의 모든 export가 자동으로 vi.fn()으로 대체된다. 이걸 auto-mocking이라 한다.
vi.spyOn() - 스파이
기존 객체의 메서드를 감시하면서 원래 구현을 유지하거나 대체할 수 있다.
const spy = vi.spyOn(console, 'log');
doSomething();
expect(spy).toHaveBeenCalledWith('expected message');
spy.mockRestore(); // 원래 구현 복원
vi.fn()과 달리 기존 객체에 붙어서 동작하므로, 외부 라이브러리나 전역 객체의 메서드를 테스트할 때 유용하다.
@testing-library/react 연동
React 컴포넌트 테스트에서 Vitest는 @testing-library/react와 함께 사용하는 게 일반적이다. Testing Library는 사용자 관점에서 컴포넌트를 테스트하는 철학을 가지고 있어서, DOM 구현 세부사항이 아니라 사용자가 보고 상호작용하는 방식으로 테스트를 작성한다.
import { render, screen, fireEvent } from '@testing-library/react';
describe('Counter', () => {
it('should increment count on button click', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
expect(screen.getByText('Count: 0')).toBeInTheDocument();
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
쿼리 우선순위
Testing Library는 여러 쿼리 방식을 제공하는데, 접근성 기반 쿼리를 우선 사용하는 것이 권장된다.
| 우선순위 | 쿼리 | 설명 |
|---|---|---|
| 1 | getByRole | ARIA 역할로 찾기 (button, heading 등) |
| 2 | getByLabelText | label과 연결된 input 찾기 |
| 3 | getByPlaceholderText | placeholder로 찾기 |
| 4 | getByText | 텍스트 콘텐츠로 찾기 |
| 5 | getByTestId | data-testid 속성으로 찾기 (최후의 수단) |
getByTestId는 사용자가 실제로 인지하는 요소가 아니므로 다른 쿼리로 찾을 수 없을 때만 사용한다.
비동기 테스트
API 호출이나 상태 업데이트 같은 비동기 동작을 테스트할 때는 waitFor나 findBy* 쿼리를 사용한다.
import { render, screen, waitFor } from '@testing-library/react';
it('should load and display user data', async () => {
render(<UserProfile userId="1" />);
// 로딩 상태 확인
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 데이터 로드 완료 대기
const userName = await screen.findByText('Alice');
expect(userName).toBeInTheDocument();
// 또는 waitFor 사용
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
findBy*는 getBy*의 비동기 버전으로, 기본 1초 동안 해당 요소가 나타날 때까지 기다린다. waitFor는 콜백 안의 assertion이 통과할 때까지 반복 실행한다.
userEvent
fireEvent보다 더 실제적인 사용자 상호작용을 시뮬레이션하려면 @testing-library/user-event를 사용한다.
import userEvent from '@testing-library/user-event';
it('should handle form submission', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText('Welcome!')).toBeInTheDocument();
});
fireEvent.change()는 change 이벤트만 발생시키지만, userEvent.type()은 focus → keyDown → keyPress → input → keyUp 이벤트를 순서대로 발생시킨다. 실제 사용자의 타이핑 동작과 더 가까운 시뮬레이션이다.
테스트 실행
# 모든 테스트 실행
npx vitest run
# 감시 모드 (파일 변경 시 자동 재실행)
npx vitest
# 특정 파일만 실행
npx vitest run src/components/Button.test.tsx
# 패턴 매칭
npx vitest run --reporter=verbose "auth"
# UI 모드 (브라우저에서 테스트 결과 확인)
npx vitest --ui
기본적으로 npx vitest (인자 없이)를 실행하면 감시(watch) 모드로 동작한다. 파일을 수정하면 관련 테스트만 자동으로 다시 실행된다. Vite의 HMR 인프라를 활용하기 때문에 변경된 모듈만 다시 변환해서 실행 속도가 빠르다.
--ui 플래그를 사용하면 @vitest/ui 패키지가 제공하는 웹 인터페이스에서 테스트 결과를 시각적으로 확인할 수 있다. 각 테스트의 상태, 소요 시간, 에러 내용을 브라우저에서 편하게 볼 수 있다.
성능
Vitest가 Jest보다 빠른 이유는 크게 세 가지다.
첫째, Vite의 변환 파이프라인을 사용한다. Jest는 테스트 파일을 실행하기 전에 Babel이나 ts-jest로 변환하는데, Vitest는 esbuild 기반의 Vite 변환을 사용해서 훨씬 빠르다.
둘째, 스마트한 파일 감시 시스템이다. Vite의 모듈 그래프를 활용해서 변경된 파일에 의존하는 테스트만 다시 실행한다. Jest도 --changedSince 같은 옵션이 있지만, Vite의 HMR 수준의 정밀한 의존성 추적에는 미치지 못한다.
셋째, 워커 스레드 기반 병렬 실행이다. 기본적으로 테스트 파일을 워커 스레드에서 병렬로 실행한다. --pool 옵션으로 실행 방식을 제어할 수 있다.
// vitest.config.ts
export default defineConfig({
test: {
pool: 'threads', // 워커 스레드 (기본값)
// pool: 'forks', // child_process.fork
// pool: 'vmThreads', // VM 컨텍스트 격리 + 스레드
poolOptions: {
threads: {
minThreads: 1,
maxThreads: 4,
},
},
},
});
인라인 스냅샷
Vitest는 Jest와 마찬가지로 스냅샷 테스트를 지원하는데, 인라인 스냅샷이 특히 편리하다.
it('should format date correctly', () => {
expect(formatDate('2024-01-15')).toMatchInlineSnapshot(`"2024년 1월 15일"`);
});
처음 실행할 때 toMatchInlineSnapshot() 안에 실제 결과가 자동으로 채워진다. 이후 실행에서는 채워진 값과 비교해서 변경 여부를 감지한다. 별도 스냅샷 파일이 생기지 않아서 코드 리뷰 시 변경 내용을 바로 확인할 수 있다.
타입 테스트
Vitest는 expectTypeOf를 통해 타입 수준의 테스트도 지원한다. 런타임 동작이 아니라 TypeScript 타입이 올바른지 검증한다.
import { expectTypeOf } from 'vitest';
it('should return correct types', () => {
expectTypeOf(parseConfig).toBeFunction();
expectTypeOf(parseConfig).parameter(0).toBeString();
expectTypeOf(parseConfig).returns.toEqualTypeOf<Config>();
});
이건 실제로 코드를 실행하지 않고 타입 체커를 통해 검증하는 것이다. 라이브러리의 공개 API 타입이 의도대로 추론되는지 확인할 때 유용하다.
정리
- Vite의 설정(alias, plugin, transform)을 그대로 재사용하므로 별도 변환 설정 없이 테스트 환경이 구성된다
- vi.mock()/vi.fn()/vi.spyOn()으로 Jest와 거의 동일한 모킹 API를 제공하되, ESM 환경에서는 vi.hoisted()로 호이스팅을 제어한다
- HMR 기반 파일 감시로 변경된 모듈의 테스트만 재실행하고, 워커 스레드 병렬 실행으로 속도를 확보한다
관련 문서
- Testcontainers - Docker 기반 통합 테스트
- Jest / ts-jest - Jest 테스트 러너
- Storybook 9 + Chromatic - 컴포넌트 시각적 테스트