왜 Vitest인가?
NestJS의 기본 테스트 러너는 Jest다. 하지만 Jest는 CommonJS 기반으로 설계되어 ESM 지원이 불완전하고, 대규모 프로젝트에서 변환(transform) 오버헤드로 인해 느려진다. Vitest는 Vite의 빌드 파이프라인을 활용해 네이티브 ESM, TypeScript, 핫 리로드를 기본 지원하며, Jest 호환 API를 제공해 마이그레이션 비용이 거의 없다.
| 항목 | Jest | Vitest |
|---|---|---|
| TypeScript 변환 | ts-jest / @swc/jest 필요 | 내장 (esbuild/SWC) |
| ESM 지원 | ⚠️ 실험적 | ✅ 네이티브 |
| Watch 모드 | 전체 재실행 | HMR 기반 즉시 반영 |
| 실행 속도 (200 테스트) | ~12s | ~3s |
| 설정 파일 | jest.config.ts | vitest.config.ts (Vite 확장) |
설치 및 설정
# 의존성 설치
npm install -D vitest unplugin-swc @swc/core
npm install -D @vitest/coverage-v8 # 커버리지
# Jest 관련 패키지 제거 (선택)
npm uninstall jest ts-jest @types/jest @jest/globals
vitest.config.ts
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
import path from 'path';
export default defineConfig({
test: {
globals: true, // describe, it, expect 전역 사용
root: './',
environment: 'node',
include: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
// NestJS 데코레이터 메타데이터를 위한 SWC 플러그인
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.module.ts',
'src/main.ts',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// 테스트 타임아웃
testTimeout: 30000,
hookTimeout: 30000,
},
resolve: {
alias: {
'@app': path.resolve(__dirname, 'src'),
'@modules': path.resolve(__dirname, 'src/modules'),
'@common': path.resolve(__dirname, 'src/common'),
},
},
plugins: [
// SWC 플러그인: 데코레이터 메타데이터 emit (NestJS DI 필수)
swc.vite({
module: { type: 'es6' },
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
target: 'es2022',
keepClassNames: true,
},
}),
],
});
test/setup.ts
// 전역 설정: reflect-metadata 로드 (NestJS DI 필수)
import 'reflect-metadata';
package.json 스크립트
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
}
}
단위 테스트: Service 테스트
// order.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { OrderService } from './order.service';
import { OrderRepository } from './order.repository';
import { EventEmitter2 } from '@nestjs/event-emitter';
describe('OrderService', () => {
let service: OrderService;
let orderRepo: OrderRepository;
let eventEmitter: EventEmitter2;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrderService,
{
provide: OrderRepository,
useValue: {
save: vi.fn(), // vi.fn() = jest.fn()
findById: vi.fn(),
findByUserId: vi.fn(),
},
},
{
provide: EventEmitter2,
useValue: {
emit: vi.fn(),
},
},
],
}).compile();
service = module.get(OrderService);
orderRepo = module.get(OrderRepository);
eventEmitter = module.get(EventEmitter2);
});
describe('createOrder', () => {
it('주문을 생성하고 이벤트를 발행해야 한다', async () => {
// Arrange
const dto = { productId: 1, userId: 'user_1', amount: 50000 };
const savedOrder = { id: 1, ...dto, status: 'PENDING' };
vi.mocked(orderRepo.save).mockResolvedValue(savedOrder);
// Act
const result = await service.createOrder(dto);
// Assert
expect(result).toEqual(savedOrder);
expect(orderRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
productId: 1,
status: 'PENDING',
}),
);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'order.created',
expect.objectContaining({ id: 1 }),
);
});
it('금액이 0 이하면 예외를 던져야 한다', async () => {
const dto = { productId: 1, userId: 'user_1', amount: -1000 };
await expect(service.createOrder(dto))
.rejects.toThrow('금액은 0보다 커야 합니다');
});
});
describe('getOrder', () => {
it('존재하지 않는 주문 조회 시 NotFoundException', async () => {
vi.mocked(orderRepo.findById).mockResolvedValue(null);
await expect(service.getOrder(999))
.rejects.toThrow('주문을 찾을 수 없습니다');
});
});
});
vi.fn() vs jest.fn(): 차이점
// Vitest의 vi는 Jest의 jest와 거의 동일한 API
import { vi } from 'vitest';
// Mock 함수
const mockFn = vi.fn();
const mockImpl = vi.fn().mockReturnValue(42);
const mockAsync = vi.fn().mockResolvedValue({ id: 1 });
// 모듈 Mock
vi.mock('./payment.service', () => ({
PaymentService: vi.fn().mockImplementation(() => ({
charge: vi.fn().mockResolvedValue({ success: true }),
})),
}));
// 타이머 Mock
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-08'));
// 테스트 후
vi.useRealTimers();
// Spy
const spy = vi.spyOn(service, 'validateOrder');
expect(spy).toHaveBeenCalledOnce();
// 모든 Mock 초기화
vi.clearAllMocks(); // 호출 기록만 초기화
vi.resetAllMocks(); // 구현도 초기화
vi.restoreAllMocks(); // 원래 구현으로 복원
E2E 테스트: supertest 통합
// vitest.e2e.config.ts — E2E 전용 설정
import { defineConfig, mergeConfig } from 'vitest/config';
import baseConfig from './vitest.config';
export default mergeConfig(baseConfig, defineConfig({
test: {
include: ['**/*.e2e-spec.ts'],
testTimeout: 60000,
hookTimeout: 60000,
pool: 'forks', // E2E는 fork 모드 권장
poolOptions: {
forks: {
singleFork: true, // 포트 충돌 방지
},
},
},
}));
// order.e2e-spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '@app/app.module';
describe('OrderController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('POST /orders', () => {
it('201 — 주문 생성 성공', async () => {
const response = await request(app.getHttpServer())
.post('/orders')
.send({
productId: 1,
amount: 50000,
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
status: 'PENDING',
amount: 50000,
});
});
it('400 — 유효하지 않은 요청', async () => {
await request(app.getHttpServer())
.post('/orders')
.send({ productId: 'invalid' })
.expect(400);
});
});
describe('GET /orders/:id', () => {
it('404 — 존재하지 않는 주문', async () => {
await request(app.getHttpServer())
.get('/orders/99999')
.expect(404);
});
});
});
In-Source Testing: 소스 파일 내 테스트
Vitest의 독특한 기능으로, 소스 코드와 테스트를 같은 파일에 작성할 수 있다. 유틸리티 함수에 특히 유용하다.
// utils/price.util.ts
export function calculateDiscount(
price: number,
discountPercent: number,
): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('할인율은 0~100 사이여야 합니다');
}
return Math.round(price * (1 - discountPercent / 100));
}
export function formatPrice(price: number): string {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(price);
}
// 프로덕션 빌드에서 자동 제거됨 (tree-shaking)
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest;
describe('calculateDiscount', () => {
it('10% 할인 적용', () => {
expect(calculateDiscount(10000, 10)).toBe(9000);
});
it('잘못된 할인율 예외', () => {
expect(() => calculateDiscount(10000, -5)).toThrow();
expect(() => calculateDiscount(10000, 150)).toThrow();
});
});
describe('formatPrice', () => {
it('한국 원화 포맷', () => {
expect(formatPrice(50000)).toBe('₩50,000');
});
});
}
// vitest.config.ts에서 활성화
export default defineConfig({
test: {
includeSource: ['src/**/*.ts'], // In-Source 테스트 활성화
},
define: {
'import.meta.vitest': 'undefined', // 프로덕션 빌드에서 제거
},
});
Vitest UI: 브라우저 기반 테스트 대시보드
# 설치
npm install -D @vitest/ui
# 실행
npx vitest --ui
# → http://localhost:51204 에서 테스트 대시보드 확인
# 기능:
# - 테스트 트리 시각화
# - 실시간 Watch 결과
# - 모듈 의존성 그래프
# - 커버리지 인라인 표시
Jest → Vitest 마이그레이션 체크리스트
// 1. import 변경
// Before (Jest)
// describe, it, expect는 전역
// After (Vitest) — globals: true면 변경 불필요
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 2. Mock 함수 변경
// jest.fn() → vi.fn()
// jest.mock() → vi.mock()
// jest.spyOn() → vi.spyOn()
// jest.useFakeTimers() → vi.useFakeTimers()
// 3. 자동 치환 스크립트
// npx codemod -p vitest/jest (자동 마이그레이션 도구)
// 4. 타입 변경
// @types/jest 제거 → vitest/globals 타입 자동 포함
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"] // @types/jest 대신
}
}
CI 설정: GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
Vitest는 NestJS 테스트의 차세대 표준이 될 잠재력이 있다. Jest 호환 API로 마이그레이션 비용이 거의 없으면서, 빌드 속도·Watch 모드·ESM 지원에서 압도적 우위를 보인다. unplugin-swc로 데코레이터 메타데이터를 처리하면 NestJS의 DI와 완벽히 호환된다.