NestJS Vitest 테스트 전환

왜 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와 완벽히 호환된다.

관련 글: NestJS Testing 실전 가이드 | NestJS SWC 빌드 최적화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux