16.1 Vitest + TypeScript — 빠른 단위 테스트
Vitest란?
Vitest는 Vite 기반의 초고속 테스트 프레임워크입니다. TypeScript를 기본 지원하고, Jest 호환 API를 제공해 기존 Jest 테스트를 그대로 사용할 수 있습니다.
npm install --save-dev vitest @vitest/coverage-v8
vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true, // describe, it, expect 전역 사용
environment: 'node', // 또는 'jsdom' (브라우저 환경)
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
include: ['**/*.{test,spec}.{ts,tsx}'],
},
})
기본 테스트 작성
// math.ts
export function add(a: number, b: number): number {
return a + b
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('0으로 나눌 수 없습니다.')
return a / b
}
export async function fetchUser(id: string): Promise<{ id: string; name: string }> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('사용자를 찾을 수 없습니다.')
return response.json()
}
// math.test.ts
import { describe, it, expect } from 'vitest'
import { add, divide } from './math'
describe('add', () => {
it('두 숫자를 더한다', () => {
expect(add(1, 2)).toBe(3)
expect(add(-1, 1)).toBe(0)
expect(add(0.1, 0.2)).toBeCloseTo(0.3)
})
it('큰 숫자도 처리한다', () => {
expect(add(1_000_000, 2_000_000)).toBe(3_000_000)
})
})
describe('divide', () => {
it('나누기를 수행한다', () => {
expect(divide(10, 2)).toBe(5)
expect(divide(7, 2)).toBe(3.5)
})
it('0으로 나누면 에러를 던진다', () => {
expect(() => divide(10, 0)).toThrow('0으로 나눌 수 없습니다.')
expect(() => divide(10, 0)).toThrowError(Error)
})
})
Matcher 활용
import { describe, it, expect } from 'vitest'
describe('Matcher 예제', () => {
it('기본 매처', () => {
// 동등성
expect(1 + 1).toBe(2) // 원시값 비교 (===)
expect({ a: 1 }).toEqual({ a: 1 }) // 깊은 비교
expect({ a: 1 }).toStrictEqual({ a: 1 }) // 타입까지 비교
// Truthy/Falsy
expect(true).toBeTruthy()
expect(null).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect(0).toBeDefined()
// 숫자
expect(10).toBeGreaterThan(5)
expect(10).toBeLessThanOrEqual(10)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
// 문자열
expect('Hello World').toContain('World')
expect('typescript').toMatch(/script/)
// 배열/객체
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect({ name: 'Alice', age: 30 }).toMatchObject({ name: 'Alice' })
})
it('에러 매처', () => {
const throwError = () => { throw new TypeError('타입 에러') }
expect(throwError).toThrow()
expect(throwError).toThrow(TypeError)
expect(throwError).toThrow('타입 에러')
})
})
Mock 함수 (vi.fn)
import { describe, it, expect, vi, beforeEach } from 'vitest'
// 서비스 인터페이스
interface EmailService {
sendWelcome(to: string): Promise<void>
sendReset(to: string, token: string): Promise<void>
}
// 테스트 대상 — 의존성 주입
class UserRegistrationService {
constructor(private emailService: EmailService) {}
async register(email: string) {
// 비즈니스 로직...
await this.emailService.sendWelcome(email)
return { email, createdAt: new Date() }
}
}
describe('UserRegistrationService', () => {
let mockEmailService: EmailService
let service: UserRegistrationService
beforeEach(() => {
mockEmailService = {
sendWelcome: vi.fn().mockResolvedValue(undefined),
sendReset: vi.fn().mockResolvedValue(undefined),
}
service = new UserRegistrationService(mockEmailService)
})
it('회원가입 시 환영 이메일을 보낸다', async () => {
await service.register('alice@example.com')
expect(mockEmailService.sendWelcome).toHaveBeenCalledOnce()
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith('alice@example.com')
})
it('등록된 사용자 정보를 반환한다', async () => {
const result = await service.register('alice@example.com')
expect(result).toMatchObject({ email: 'alice@example.com' })
expect(result.createdAt).toBeInstanceOf(Date)
})
})
모듈 모킹 (vi.mock)
// user.service.ts
import { prisma } from './lib/prisma'
export async function getUserById(id: string) {
return prisma.user.findUnique({ where: { id } })
}
// user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getUserById } from './user.service'
// 모듈 모킹 — import 전에 선언
vi.mock('./lib/prisma', () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}))
import { prisma } from './lib/prisma'
describe('getUserById', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('사용자를 반환한다', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' }
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
const result = await getUserById('1')
expect(result).toEqual(mockUser)
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' } })
})
it('존재하지 않으면 null을 반환한다', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
const result = await getUserById('999')
expect(result).toBeNull()
})
})
타이머 모킹
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function retryWithDelay<T>(fn: () => Promise<T>, retries: number): Promise<T> {
for (let i = 0; i < retries; i++) {
try {
return await fn()
} catch {
if (i < retries - 1) await delay(1000 * (i + 1))
}
}
throw new Error('최대 재시도 횟수 초과')
}
describe('retryWithDelay', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('성공 시 결과를 반환한다', async () => {
const fn = vi.fn().mockResolvedValue('success')
const result = await retryWithDelay(fn, 3)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledOnce()
})
it('실패 후 재시도한다', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('첫 실패'))
.mockResolvedValue('success')
const promise = retryWithDelay(fn, 3)
await vi.runAllTimersAsync()
const result = await promise
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})
})
고수 팁
스냅샷 테스트
import { describe, it, expect } from 'vitest'
function formatUser(user: { name: string; email: string; role: string }) {
return `[${user.role.toUpperCase()}] ${user.name} <${user.email}>`
}
describe('formatUser', () => {
it('스냅샷과 일치한다', () => {
const result = formatUser({ name: 'Alice', email: 'alice@example.com', role: 'admin' })
expect(result).toMatchInlineSnapshot(`"[ADMIN] Alice <alice@example.com>"`)
})
})
package.json 스크립트 설정
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}