목 함수

목 함수 (Mock Functions)

테스트를 작성하다 보면 실제 함수나 모듈을 내가 통제하는 버전으로 바꿔야 할 때가 반드시 와요. 이걸 모킹(mocking) 이라고 불러요. 이유는 여러 가지가 있어요 — 실제 함수가 테스트를 느리게 만드는 네트워크 요청을 보낸다거나, 실제 코드로는 재현하기 어려운 에러를 시뮬레이션해야 한다거나 할 때죠. 목 함수는 의존성이 무엇을 반환할지 조절하고, 어떻게 호출됐는지 관찰하며, 테스트 대상 코드를 부수 효과에서 격리시켜 줘요. Vitest 는 이 모킹 유틸들을 vi 객체로 제공해요.

출처: Vitest — Mock Functions

목 함수 만들기

목을 만드는 가장 단순한 방법은 vi.fn() 이에요. 기본적으로 아무것도 하지 않는(그래서 undefined 를 반환하는) 함수를 주는데, 대신 자기에게 걸린 모든 호출을 추적해요:

import { expect, test, vi } from 'vitest'

test('mock function basics', () => {
  const getApples = vi.fn()

  // Call it
  getApples()

  // Check it was called
  expect(getApples).toHaveBeenCalled()
  expect(getApples).toHaveBeenCalledTimes(1)

  // By default, a mock returns undefined
  expect(getApples()).toBeUndefined()
})

목 반환값

항상 undefined 만 반환하는 목은 그 자체로는 별 쓸모가 없어요. 보통은 반환값을 조절해서 코드가 서로 다른 값에 어떻게 반응하는지 테스트하고 싶을 거예요:

import { expect, test, vi } from 'vitest'

test('mock return values', () => {
  const getApples = vi.fn()

  // Always return this value
  getApples.mockReturnValue(10)
  expect(getApples()).toBe(10)

  // Return this value only once, then fall back to the default
  getApples.mockReturnValueOnce(20)
  expect(getApples()).toBe(20) // 20 (one-time)
  expect(getApples()).toBe(10) // back to default
})

모킹하는 함수가 비동기라면 mockResolvedValuemockRejectedValue 로 promise 의 결과를 조절해요:

test('mock async return values', async () => {
  const fetchUser = vi.fn()

  fetchUser.mockResolvedValue({ name: 'Alice' })
  const user = await fetchUser()
  expect(user.name).toBe('Alice')

  fetchUser.mockRejectedValue(new Error('Not found'))
  await expect(fetchUser()).rejects.toThrow('Not found')
})

::: tip mockReturnValue 는 목이 받는 인자와 무관하게 항상 같은 값을 반환해요. 인자에 따라 반환값을 다르게 하고 싶다면 vi.when 으로 인자 조합별로 다른 동작을 붙일 수 있어요. 직접 if/else 를 쓸 필요 없이요. 자세한 내용은 Conditional Mocking 레시피를 보세요. :::

목 구현 (Mock Implementation)

고정된 반환값만으론 부족할 때가 있어요. 목이 인자를 받아 실제로 뭔가 하길 원할 때죠. mockImplementation 은 완전한 대체 함수를 제공하게 해줘요:

import { expect, test, vi } from 'vitest'

test('mock with custom implementation', () => {
  const add = vi.fn()
  add.mockImplementation((a, b) => a + b)

  expect(add(1, 2)).toBe(3)
  expect(add(10, 20)).toBe(30)
})

단축으로, 구현을 vi.fn() 에 직접 넘길 수도 있어요:

const add = vi.fn((a, b) => a + b)

호출 기록 검사하기

목 함수의 가장 강력한 점 중 하나는 자기에게 걸린 모든 호출을 기억한다는 거예요. 함수가 몇 번 호출됐는지, 어떤 인자를 받았는지, 무엇을 반환했는지를 단언할 수 있어요:

import { expect, test, vi } from 'vitest'

test('inspecting mock calls', () => {
  const greet = vi.fn()

  greet('Alice')
  greet('Bob', 'Charlie')

  // Number of calls
  expect(greet).toHaveBeenCalledTimes(2)

  // Check specific arguments
  expect(greet).toHaveBeenCalledWith('Alice')
  expect(greet).toHaveBeenCalledWith('Bob', 'Charlie')

  // Check the arguments of a specific call by position
  expect(greet).toHaveBeenNthCalledWith(1, 'Alice')
  expect(greet).toHaveBeenLastCalledWith('Bob', 'Charlie')

  // Access the raw call data
  expect(greet.mock.calls).toEqual([
    ['Alice'],
    ['Bob', 'Charlie'],
  ])
})

.mock 프로퍼티는 호출 기록 전체에 접근하게 해줘요. .mock.calls 외에 .mock.results 로 각 호출이 무엇을 반환했는지(또는 던졌는지) 볼 수 있어요:

const double = vi.fn(x => x * 2)

double(5)
double(10)

expect(double.mock.results).toEqual([
  { type: 'return', value: 10 },
  { type: 'return', value: 20 },
])

::: warning .mock.calls 는 인자의 복사본이 아니라 참조를 저장해요. 목에 객체를 넘긴 뒤 그 객체를 변경하면, 기록된 호출도 호출 당시의 상태가 아니라 변경된 상태를 반영해요:

const fn = vi.fn()
const obj = { count: 1 }

fn(obj)
obj.count = 2

// ❌ This fails! mock.calls[0][0].count is now 2, not 1
expect(fn).toHaveBeenCalledWith({ count: 1 })

원래 값을 단언해야 한다면 mockImplementation 으로 호출 시점의 복제본을 잡아두면 돼요:

const calls = []
const fn = vi.fn((obj) => {
  calls.push(structuredClone(obj))
})

const obj = { count: 1 }
fn(obj)
obj.count = 2

expect(calls[0]).toEqual({ count: 1 }) // ✅ passes

아니면 객체가 바뀌기 전에 단언을 먼저 하면 됩니다. :::

메서드 감시하기 (Spying on Methods)

vi.spyOnvi.fn() 과 중요한 차이가 있어요. 완전히 새로운 함수를 만드는 대신, 객체의 기존 메서드를 감싸요. 기본적으로 원래 구현은 그대로 동작하지만, 모든 호출을 관찰할 수 있고 선택적으로 동작을 덮어쓸 수도 있어요:

import { expect, test, vi } from 'vitest'

const calculator = {
  add(a, b) {
    return a + b
  },
}

test('spy on a method', () => {
  const spy = vi.spyOn(calculator, 'add')

  // The original implementation still works
  expect(calculator.add(1, 2)).toBe(3)

  // But we can observe calls
  expect(spy).toHaveBeenCalledWith(1, 2)
  expect(spy).toHaveBeenCalledTimes(1)
})

test('spy can override implementation', () => {
  const spy = vi.spyOn(calculator, 'add')
  spy.mockReturnValue(42)

  expect(calculator.add(1, 2)).toBe(42)
})

이건 특히 코드가 메서드를 올바르게 호출하는지, 그 메서드의 동작을 통째로 바꾸지 않고 확인하고 싶을 때 유용해요.

목 초기화하기 (Resetting Mocks)

목 함수는 테스트가 돌면서 상태를 쌓아요. 모든 호출, 모든 반환값, 설정한 커스텀 구현을 기억하죠. 테스트 사이에 초기화하지 않으면 이 상태가 새어 나가서 헷갈리는 실패를 일으킬 수 있어요. Vitest 는 세 단계의 정리 수준을 제공해요:

  • mockClear() — 기록된 호출 기록과 반환값을 비우되, 설정한 커스텀 구현은 유지
  • mockReset()mockClear 가 하는 모든 일에 더해 커스텀 구현도 제거해서 목을 기본 상태로 되돌림
  • mockRestore()vi.spyOn 으로 만든 스파이 전용. 원래 객체 메서드를 복원해서 스파이를 사실상 되돌림. vi.fn() 목에는 mockReset 과 동일하게 동작

실무에서는 각 테스트 후에 모든 목을 자동으로 복원하는 게 가장 쉽습니다:

import { afterEach, expect, test, vi } from 'vitest'

const calculator = {
  add: (a, b) => a + b,
}

afterEach(() => {
  vi.restoreAllMocks()
})

test('spy is restored after the test', () => {
  const spy = vi.spyOn(calculator, 'add').mockReturnValue(42)
  expect(calculator.add(1, 2)).toBe(42)
  // afterEach will restore calculator.add to the original implementation
})

더 나아가 restoreMocks 설정으로 전역에 지정하면 afterEach 조차 필요 없어요:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    restoreMocks: true,
  },
})

모듈 모킹

단일 함수가 아니라 전체 모듈을 통째로 교체해야 할 때도 있어요. 예를 들어 테스트 중에 실행하고 싶지 않은 데이터베이스 클라이언트나 로거가 그렇죠. vi.mock 은 모듈의 export 를 목 구현으로 바꿔줘요:

import { expect, test, vi } from 'vitest'
import { getUser } from './db.js'

vi.mock(import('./db.js'), () => ({
  getUser: vi.fn(),
}))

test('mock a module', () => {
  vi.mocked(getUser).mockReturnValue({ name: 'Alice' })

  const user = getUser(1)
  expect(user.name).toBe('Alice')
  expect(getUser).toHaveBeenCalledWith(1)
})

::: warning vi.mock 호출은 파일 상단으로 호이스팅돼요. 모든 import 보다 먼저 실행되죠. 그래서 테스트 코드가 돌 때는 이미 목 버전이 자리를 잡고 있는 거예요. :::

::: warning 어떤 경우든 문자열 './db.js' 대신 import('./db.js') 를 넘기는 걸 잊지 마세요. import() 를 쓰면 TypeScript 가 모듈의 타입을 추론할 수 있어서, 팩토리 함수의 반환값이 타입 검사되고 importOriginal 도 올바르게 타입이 지정된 모듈을 반환해요. 덤으로 파일을 IDE 에서 이름을 바꾸거나 이동하면 import 경로도 자동으로 갱신돼요. 문자열을 쓰면 타입 안전성과 자동 리팩터링을 둘 다 잃어버려요. :::

Vitest 는 구체적인 모킹 시나리오별로 방대한 가이드를 준비해 뒀어요:

더 알아보기 (Learn more)

  • Mocking — 모킹 전반의 치트시트·레퍼런스
  • vi APIvi 객체의 전체 API
  • Mock API — 목 함수의 메서드 레퍼런스
  • Snapshot Testing — 다음 단계로 스냅샷 테스트