개요

Vite 기반의 테스트 프레임워크 Vitest 소개 및 실무 사용 패턴 정리 Jest와 거의 동일한 API를 제공하고, Vite의 ESM/HMR을 활용해 빠른 재실행을 제공함 Vite 프로젝트에서는 최소 설정으로 바로 사용 가능

  • 빠른 실행과 재실행, watch 효율 높음
  • Jest API 호환성 높아 러닝 커브 낮음
  • TypeScript 네이티브 지원

1. 기본 구조

테스트 파일 명명 규칙

*.spec.ts     또는     *.test.ts

describe(name, fn)

관련 테스트를 그룹화하는 컨테이너

import { describe, it, expect } from 'vitest'

describe('계산기', () => {
  it('두 수를 더한다', () => {
    expect(1 + 2).toBe(3)
  })

  it('두 수를 뺀다', () => {
    expect(5 - 3).toBe(2)
  })
})
  • 첫 번째 인자: 그룹 이름
  • 두 번째 인자: 테스트 콜백
  • 중첩 describe 구성 가능

it(name, fn) / test(name, fn)

개별 테스트 케이스 정의, 두 API는 동일 동작

it('유효한 이메일이면 true를 반환', () => {
  expect(isValidEmail('test@example.com')).toBe(true)
})

test('유효한 이메일이면 true를 반환', () => {
  expect(isValidEmail('test@example.com')).toBe(true)
})

중첩 describe 출력 예

describe('UserService', () => {
  describe('create', () => {
    it('유효한 데이터로 유저 생성', () => { /* ... */ })
    it('중복 이메일이면 에러', () => { /* ... */ })
  })

  describe('delete', () => {
    it('존재하는 유저 삭제', () => { /* ... */ })
  })
})
UserService
  create
    ✓ 유효한 데이터로 유저 생성
    ✓ 중복 이메일이면 에러
  delete
    ✓ 존재하는 유저 삭제

2. expect와 매처

expect(value)

검증 시작점, 다양한 매처 체이닝 지원

expect(실제값).matcher(기대값)

기본 비교 매처

toBe(expected)

엄격 동등 비교 ===, 원시값 비교에 사용

expect(1 + 2).toBe(3)
expect('hello').toBe('hello')
expect(true).toBe(true)
// 객체/배열은 참조 비교라 실패
expect({ a: 1 }).toBe({ a: 1 }) // 실패

toEqual(expected)

깊은 동등 비교, 객체/배열 내용 비교

expect({ name: 'John', age: 30 }).toEqual({ name: 'John', age: 30 })
expect([1, 2, 3]).toEqual([1, 2, 3])
expect({ user: { name: 'John' }, items: [1, 2] }).toEqual({ user: { name: 'John' }, items: [1, 2] })

toStrictEqual(expected)

toEqual보다 엄격, undefined 속성·희소 배열·클래스 인스턴스까지 체크

expect({ a: 1, b: undefined }).toEqual({ a: 1 })
expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 }) // 실패

class User { name = 'John' }
expect(new User()).toEqual({ name: 'John' })
expect(new User()).toStrictEqual({ name: 'John' }) // 실패

Truthiness 매처

toBeTruthy / toBeFalsy

JavaScript truthy/falsy 규칙 기반 검증

// Truthy: 0, '', null, undefined, NaN, false 외의 모든 값
expect(1).toBeTruthy()
expect('hello').toBeTruthy()
expect([]).toBeTruthy()
expect({}).toBeTruthy()

// Falsy: 0, '', null, undefined, NaN, false
expect(0).toBeFalsy()
expect('').toBeFalsy()
expect(null).toBeFalsy()

toBeNull / toBeUndefined / toBeDefined

명확한 의도 표현에 유용

expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('value').toBeDefined()

숫자 비교 매처

toBeGreaterThan / toBeLessThan / toBeGreaterThanOrEqual / toBeLessThanOrEqual

expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(3).toBeLessThan(5)
expect(3).toBeLessThanOrEqual(3)

toBeCloseTo(n, precision?)

부동소수점 오차 허용 비교, precision 기본값 2

expect(0.1 + 0.2).toBeCloseTo(0.3)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)

문자열 매처

toMatch(regexp | string)

정규식 또는 부분 문자열 매칭

expect('hello world').toMatch(/world/)
expect('hello world').toMatch('world')
expect('hello world').toMatch(/^hello/)
expect('Error: 404').toMatch(/Error: \d+/)

toContain(item)

문자열에 부분 문자열 포함 여부

expect('hello world').toContain('world')

배열/이터러블 매처

toContain(item)

배열에 특정 요소 포함 여부

expect([1, 2, 3]).toContain(2)
const obj = { id: 1 }
expect([obj]).toContain(obj)
expect([{ id: 1 }]).toContain({ id: 1 }) // 실패: 참조 다름

toContainEqual(item)

배열 내 객체를 깊게 비교

expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 })

toHaveLength(n)

배열·문자열의 길이 검증

expect([1, 2, 3]).toHaveLength(3)
expect('hello').toHaveLength(5)
expect({ length: 3 }).toHaveLength(3)

예외 검증 매처

toThrow(error?)

함수가 에러를 던지는지 검증, 함수 참조를 전달해야 함

expect(() => { throw new Error('fail') }).toThrow()
expect(() => { throw new Error('Invalid email') }).toThrow('Invalid email')
expect(() => { throw new Error('User 123 not found') }).toThrow(/not found/)
class ValidationError extends Error {}
expect(() => { throw new ValidationError('invalid') }).toThrow(ValidationError)

잘못된 사용 예

// 즉시 실행되어 실패
expect(throwingFn()).toThrow()
// 올바른 사용
expect(() => throwingFn()).toThrow()

rejects.toThrow 비동기 에러

async function asyncFail() { throw new Error('async error') }
await expect(asyncFail()).rejects.toThrow('async error')
await expect(Promise.reject(new Error('fail'))).rejects.toThrow()

부정 not

모든 매처 앞에서 반대 조건 검증

expect(5).not.toBe(3)
expect([1, 2]).not.toContain(5)
expect(null).not.toBeDefined()
expect(() => safeFn()).not.toThrow()

3. Setup과 Teardown

beforeAll(fn)

모든 테스트 전 1회 실행, DB 연결·서버 시작 등 공용 리소스 준비

import { beforeAll } from 'vitest'
let db: Database
beforeAll(async () => {
  db = await Database.connect()
})

afterAll(fn)

모든 테스트 후 1회 실행, 리소스 정리

import { afterAll } from 'vitest'
afterAll(async () => {
  await db.disconnect()
})

beforeEach(fn)

각 테스트 전 매번 실행, 상태 격리

import { beforeEach, vi } from 'vitest'
beforeEach(() => {
  vi.clearAllMocks()
})

afterEach(fn)

각 테스트 후 매번 실행, 정리 작업

import { afterEach } from 'vitest'
afterEach(() => {
  cleanup()
})

실행 순서 예시

beforeAll           ← 1번
  ├── beforeEach    ← 매번
  │   └── 테스트 1
  │   └── afterEach ← 매번
  ├── beforeEach    ← 매번
  │   └── 테스트 2
  │   └── afterEach ← 매번
afterAll            ← 1번

4. Mock 함수

vi.fn(implementation?)

가짜 함수 생성, 호출 추적 및 반환값 제어 가능

import { vi } from 'vitest'
const mockFn = vi.fn()
const mockAdd = vi.fn((a, b) => a + b)
mockAdd(1, 2) // 3

사용 목적

  • 외부 의존성 격리
  • 호출 인자·횟수 검증
  • 상황에 따른 반환값 제어

Mock 함수 메서드

mockReturnValue(value)

항상 동일 값 반환

const mockFn = vi.fn()
mockFn.mockReturnValue(42)
mockFn() // 42

mockReturnValueOnce(value)

지정된 호출 횟수만 특정 값 반환 후 기본 동작

const mockFn = vi.fn()
mockFn
  .mockReturnValueOnce(1)
  .mockReturnValueOnce(2)
  .mockReturnValue(999)

mockResolvedValue(value)

Promise.resolve(value) 반환

const mockFetch = vi.fn()
mockFetch.mockResolvedValue({ data: 'success' })
await mockFetch()

동일 표현

mockFn.mockImplementation(() => Promise.resolve(value))

mockResolvedValueOnce(value)

한 번만 resolve

mockFn
  .mockResolvedValueOnce({ page: 1 })
  .mockResolvedValueOnce({ page: 2 })

mockRejectedValue(error)

Promise.reject(error) 반환

const mockFetch = vi.fn()
mockFetch.mockRejectedValue(new Error('Network error'))

mockRejectedValueOnce(error)

한 번만 reject 후 정상 동작 설정 가능

mockFn
  .mockRejectedValueOnce(new Error('일시적 에러'))
  .mockResolvedValue({ success: true })

mockImplementation(fn)

구현 자체 교체

const mockFn = vi.fn()
mockFn.mockImplementation((x, y) => x * y)

조건 분기 예

mockHttpClient.get.mockImplementation(async (url, params) => {
  if (params.page === 1) return { list: [1, 2], totalPage: 2 }
  if (params.page === 2) return { list: [3, 4], totalPage: 2 }
  return { list: [], totalPage: 2 }
})

mockImplementationOnce(fn)

일회성 구현 교체

mockFn
  .mockImplementationOnce(() => 'first')
  .mockImplementationOnce(() => 'second')
  .mockImplementation(() => 'default')

Mock 초기화 메서드

mockClear()

호출 기록만 초기화, 구현은 유지

const mockFn = vi.fn().mockReturnValue(42)
mockFn()
mockFn.mockClear()

mockReset()

호출 기록과 구현 초기화, 반환값 undefined로 복귀

const mockFn = vi.fn().mockReturnValue(42)
mockFn.mockReset()

mockRestore()

원본 구현 복구, vi.spyOn으로 만든 spy 대상

const obj = { method: () => 'original' }
const spy = vi.spyOn(obj, 'method').mockReturnValue('mocked')
spy.mockRestore()

전역 Mock 초기화

  • vi.clearAllMocks() 모든 mock 호출 기록 초기화
  • vi.resetAllMocks() 호출 기록과 구현 초기화
  • vi.restoreAllMocks() 모든 spy 원복
beforeEach(() => {
  vi.clearAllMocks()
})

Mock 검증 매처

const mockFn = vi.fn()
mockFn()
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(1)
mockFn('hello', 123)
expect(mockFn).toHaveBeenCalledWith('hello', 123)
mockFn(1)
mockFn(2)
mockFn(3)
expect(mockFn).toHaveBeenLastCalledWith(3)
expect(mockFn).toHaveBeenNthCalledWith(1, 'hello', 123)

5. 모듈 Mocking

vi.mock(path, factory?)

모듈 전체를 mock으로 교체, import 이전에 가로채기 수행

import { vi } from 'vitest'
import { fetchData } from './api' // mock된 상태로 import

vi.mock('./api', () => ({
  fetchData: vi.fn(),
  saveData: vi.fn(),
}))

동작 원리

  1. vi.mock은 파일 단위로 호이스팅되어 실제 import보다 먼저 실행
  2. 대상 모듈 로딩을 가로채 mock 버전으로 대체
  3. 이후 해당 모듈을 import하면 mock된 export 제공

Node 내장 모듈 mock

vi.mock('node:fs/promises', () => ({
  readFile: vi.fn(),
  writeFile: vi.fn(),
}))

it('파일 읽기 테스트', async () => {
  const fs = await import('node:fs/promises')
  vi.mocked(fs.readFile).mockResolvedValue('file content')
})

부분 mock

일부만 교체하고 나머지는 실제 구현 유지

vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./utils')>()
  return {
    ...actual,
    dangerousFunction: vi.fn(),
  }
})

6. vi.spyOn(object, method)

기존 메서드를 감시, 실제 구현 유지하며 호출·반환 추적 또는 선택적 교체

const calculator = { add: (a: number, b: number) => a + b }
const spy = vi.spyOn(calculator, 'add')
calculator.add(1, 2)
expect(spy).toHaveBeenCalledWith(1, 2)
expect(spy).toHaveReturnedWith(3)

vi.fn vs vi.spyOn 비교

  • vi.fn: 새 mock 함수 생성, 원본 없음, 복구 개념 없음
  • vi.spyOn: 기존 메서드 감시, 원본 유지, mockRestore로 복구 가능

구현 교체와 복구

const spy = vi.spyOn(calculator, 'add')
spy.mockImplementation((a, b) => 0)
spy.mockRestore()

7. vi.mocked(fn, deep?)

TypeScript 타입 단언 헬퍼, 런타임 영향 없음

import { fetchData } from './api'
vi.mock('./api')
// fetchData.mockResolvedValue(...) 는 타입 에러
vi.mocked(fetchData).mockResolvedValue({ data: 'test' })

deep 옵션으로 중첩 메서드까지 mock 타입 적용

const mockService = { nested: { method: vi.fn() } }
vi.mocked(mockService, true).nested.method.mockReturnValue(1)

8. 비동기 테스트

async/await

it('비동기 데이터를 가져온다', async () => {
  const data = await fetchData()
  expect(data).toEqual({ id: 1 })
})

resolves / rejects

Promise 결과를 직접 검증

await expect(asyncFn()).resolves.toBe(42)
await expect(asyncFn()).resolves.toEqual({ data: 'success' })
await expect(failingFn()).rejects.toThrow('error')
await expect(failingFn()).rejects.toBeInstanceOf(ValidationError)

9. 테스트 제어

it.only / describe.only

특정 테스트만 실행

it.only('이것만 실행', () => { /* ... */ })
describe.only('이 그룹만', () => { /* ... */ })

it.skip / describe.skip

특정 테스트 건너뜀

it.skip('이건 건너뜀', () => { /* ... */ })
describe.skip('이 그룹 건너뜀', () => { /* ... */ })

it.todo

향후 작성할 테스트 명시

it.todo('나중에 구현할 테스트')

10. Best Practices

테스트 독립성 유지

// Bad: 테스트 간 상태 공유
let counter = 0
it('테스트1', () => { counter++ })
it('테스트2', () => { expect(counter).toBe(1) })

// Good: 매 테스트 초기화
beforeEach(() => {
  vi.clearAllMocks()
})

명확한 테스트 이름

// Bad
it('test1', () => { /* ... */ })

// Good
it('유효하지 않은 이메일이면 ValidationError를 던짐', () => { /* ... */ })
it('페이지가 2개 이상이면 모든 페이지 순회', () => { /* ... */ })

AAA 패턴 적용

it('유저 생성 시 이메일 저장', async () => {
  // Arrange
  const userData = { email: 'test@example.com', name: 'John' }
  mockRepository.save.mockResolvedValue({ id: 1, ...userData })
  // Act
  const result = await userService.create(userData)
  // Assert
  expect(result.email).toBe('test@example.com')
  expect(mockRepository.save).toHaveBeenCalledWith(userData)
})

필요한 것만 mock

// Bad: 사용하지 않는 메서드까지 mock
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn(), trace: vi.fn() }
// Good: 필요한 메서드만
const mockLogger = { warn: vi.fn() } as unknown as Logger

구현이 아닌 동작을 테스트

// Bad: 내부 구현 의존
expect(service['_internalArray']).toContain(item)
// Good: 공개 인터페이스 기준 검증
expect(service.getAll()).toContain(item)

11. CLI 명령어

# 전체 테스트 실행
npx vitest run

# watch 모드
npx vitest

# 특정 파일만 실행
npx vitest user.service.spec.ts

# 테스트 이름 패턴 매칭
npx vitest --grep "유저 생성"

# 커버리지 리포트
npx vitest run --coverage

# UI 모드
npx vitest --ui

마무리

Vitest는 Vite 생태계와 맞물려 빠른 피드백 루프를 제공함 Jest 호환 API를 활용해 기존 경험을 그대로 가져오면서, 모듈 mocking과 spy, 비동기 매처를 조합해 안정적인 단위 테스트 체계를 구축 가능

참고자료