# mock·stub·spy — 테스트 더블 제대로 이해하기

> mock, stub, spy, fake… 헷갈리는 테스트 더블 용어를 정리합니다. Vitest vi.fn() 하나로 셋을 다루는 법(Jest도 거의 동일)과 어디까지 가짜로 바꿀지의 기준까지.

**Published:** 2026-07-10 | **Updated:** 2026-07-10

---


> 「프론트엔드 테스트 제대로 하기」 시리즈의 **6편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

"mock 써서 테스트했어요"라는 말, 실은 서로 다른 네 가지 개념(stub·spy·mock·fake)을 뭉뚱그린 표현입니다. 이 글 하나로 네 용어의 차이, Vitest `vi.fn()` 하나로 전부 커버하는 법, 그리고 "어디까지 가짜로 바꿀 것인가"의 기준까지 정리합니다. 한 번 정리해두면 테스트 코드를 읽고 쓰는 눈이 훨씬 선명해져요.

시리즈로 따라오셨다면 — [지난 편](/posts/frontend-testing-async/) 디바운스 테스트에 `vi.fn()`이 슬쩍 등장했었죠. "다음 편에서 제대로"라던 약속을 지킬 시간입니다. 검색으로 처음 오셨어도 예제는 이 글 안에서 완결돼요. Jest 사용자라면 `vi.fn()`→`jest.fn()`, `vi.spyOn`→`jest.spyOn`으로 읽으시면 됩니다.

> 실습 코드: 이 편의 상태가 [`step-06` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-06)에 그대로 담겨 있습니다. `git checkout step-06`으로 따라오세요. 지난 편과의 차이만 보고 싶다면 [step-05와 비교](https://github.com/IsaacEryn/frontend-testing-lab/compare/step-05...step-06)도 됩니다.

## 이 질문들에 답해봅니다

- stub·spy·mock·fake는 뭐가 다른가
- 무엇을, 어디까지 가짜로 바꿀 것인가
- 과한 모킹은 왜 독인가

{{< img src="images/contents/test-doubles.png" alt="스턴트 대역에 비유한 테스트 더블 4종 비교 다이어그램 - stub은 정해진 값만 돌려주는 대역, spy는 호출을 기록하는 관찰 카메라, mock은 호출 방식까지 검증하는 대역, fake는 간단히 새로 지은 대체 세트장으로 각각 한 줄 설명과 함께 카드로 나란히 배치되어 있습니다" >}}

---

## 왜 대역이 필요한가

이런 코드를 테스트한다고 생각해보세요. 검색할 때마다 분석 SDK에 이벤트를 보내는 함수입니다.

```ts
function logSearch(query: string) {
  analytics.track('search', { query })   // 외부 분석 SDK 호출
}
```

테스트에서 이 함수를 그대로 부르면 어떻게 될까요? **진짜 분석 서버에 가짜 데이터가 쌓입니다.** 테스트를 백 번 돌리면 통계가 백 번 오염되고, 네트워크가 느리면 테스트도 느려지고, SDK 장애가 나면 내 테스트도 깨져요. 내 코드는 멀쩡한데요.

영화에서 위험한 장면에 스턴트 대역을 세우듯, 테스트에서도 **위험하거나 통제할 수 없는 상대**는 대역으로 바꿉니다. 이 대역들을 통틀어 **테스트 더블(test double)** 이라고 불러요. (더블 = 대역. 영화 용어 그대로입니다.)

---

## 대역에도 종류가 있다

같은 대역이라도 맡는 일이 다릅니다. 넷을 구분해볼게요.

| 이름 | 하는 일 | 이럴 때 |
|------|---------|---------|
| **stub** | 정해진 값만 돌려준다 | "API가 이 데이터를 준다고 치자" |
| **spy** | 호출 여부·횟수·인자를 기록한다 | "이 함수가 제대로 불렸나 확인하자" |
| **mock** | spy + "이렇게 불려야 한다"는 기대까지 검증 | "정확히 이 인자로 한 번 불려야 해" |
| **fake** | 진짜처럼 동작하는 간단한 대체 구현 | "진짜 DB 대신 배열로 흉내 내자" |

구분이 미묘하죠? 처음이라면 이렇게 잡으세요. 대역에게 시킬 수 있는 일은 사실 **두 가지뿐**입니다 — ①**질문에 답하게 하기**(내가 정한 값을 돌려줌 → stub, 그걸 제대로 된 구현으로 하면 fake) ②**현장을 목격하게 하기**(자기가 어떻게 불렸는지 기록 → spy, 그 기록을 검증까지 하면 mock). 표의 네 줄이 이 두 가지 일의 변주예요.

그리고 좋은 소식 — Vitest에서는 `vi.fn()` 하나가 stub·spy·mock 역할을 전부 겸합니다. **어떻게 쓰느냐가 곧 이름이 됩니다.**

```ts
const track = vi.fn()                    // 만들면 일단 spy (모든 호출을 기록)
track.mockReturnValue(true)              // 답을 정해주는 순간 stub
expect(track).toHaveBeenCalledWith(...)  // 기록을 검증하는 순간 mock처럼 쓴 것
```

`vi.fn()`이 만든 가짜 함수는 **자기에게 일어난 모든 일을 기억합니다.** 몇 번 불렸는지, 어떤 인자였는지, 뭘 돌려줬는지. 그 기억을 `toHaveBeenCalled`·`toHaveBeenCalledWith`·`toHaveBeenCalledOnce` 같은 매처로 꺼내 보는 거예요.

---

## 넷을 하나씩, 손에 잡히게

정의만으로는 감이 안 오니, 각각 "어떤 순간에 꺼내 드는지"를 짧은 장면으로 볼게요.

{{< img src="images/contents/doubles-in-action.png" alt="네 가지 테스트 더블이 일하는 순간을 담은 4분할 장면 - stub은 테스트가 물을 때마다 정해둔 Alice 카드만 내밀고, spy는 반환값 없는 호출을 호출 기록부에 적어두고, mock은 spy의 기록과 내 기대를 나란히 대조해 체크하고, fake는 진짜 localStorage에 빗금을 치고 new Map으로 넣고 꺼내기를 흉내 냅니다" >}}

**stub — "네가 뭘 주는지는 내가 정한다."** 검색 필터를 테스트하고 싶은데, 데이터를 주는 함수가 아직 안 만들어졌거나 느립니다. 그 자리에 정해진 답만 하는 대역을 꽂아요.

```ts
const loadUsers = vi.fn().mockReturnValue([
  { id: '1', name: 'Alice', email: 'alice@example.com' },
])

// 이제 loadUsers()는 언제나 Alice 한 명을 돌려줍니다
// 진짜 데이터 소스가 어떻든, 필터 로직만 조용히 검증할 수 있어요
```

상대의 사정(서버·구현 진척·랜덤성)과 무관하게 **내 테스트의 입력이 고정**됩니다.

**spy — "결과가 없는 함수는, 불렸는지로 검증한다."** 로그를 남기는 함수는 반환값이 없어요. `expect(결과)`에 넣을 게 없죠. 이럴 땐 관찰이 답입니다.

```ts
const track = vi.fn()
someFeature(track)          // track을 쓰는 코드 실행

expect(track).toHaveBeenCalled()   // "일단 불리긴 했나?"
```

**mock — spy에서 반 발짝 더: "정확히 이렇게 불렸어야 해."** 불린 것만으론 부족할 때, 인자와 횟수까지 못 박습니다.

```ts
expect(track).toHaveBeenCalledOnce()
expect(track).toHaveBeenCalledWith('search', { query: 'alice' })
```

Vitest에선 spy와 mock이 같은 `vi.fn()`이고, **검증을 얼마나 구체적으로 하느냐**의 차이일 뿐이에요.

**fake — "간단하게라도, 진짜처럼 동작해야 할 때."** 값 하나 돌려주는 걸로는 부족하고, 저장하면 꺼낼 수 있어야 하는 상대(저장소 같은 것)라면 미니 구현을 새로 짓습니다.

```ts
const fakeStorage = new Map<string, string>()
// setItem/getItem 대신 Map — 진짜 localStorage처럼 '동작'하지만
// 브라우저도, 디스크도 필요 없습니다
```

### 어느 걸 꺼낼지 30초 판정

- 대역이 **값을 줘야** 테스트가 진행된다 → **stub**
- 대역이 **불렸는지·어떻게 불렸는지**가 곧 검증 대상이다 → **spy** (인자·횟수까지 못 박으면 **mock**)
- 대역이 **상태를 기억하며 진짜처럼 굴러야** 한다 → **fake**
- 헷갈리면 — 일단 `vi.fn()`으로 시작하세요. 쓰다 보면 위 셋 중 하나가 되어 있습니다.

---

## 실전: 분석 로거를 대역으로 테스트하기

데모 앱에 진짜로 넣을 코드로 해봅시다. 검색어를 다듬어 분석 이벤트로 기록하는 로거 — `src/lib/analytics.ts`를 만들어주세요.

```ts
export type TrackFn = (event: string, payload?: Record<string, unknown>) => void

/** 검색어를 다듬어 분석 이벤트로 기록 (빈 검색어는 무시) */
export function createSearchLogger(track: TrackFn) {
  return (query: string) => {
    const q = query.trim()
    if (q) track('search', { query: q })
  }
}
```

{{< img src="images/contents/logger-injection.png" alt="의존성 주입을 소켓과 플러그로 표현한 다이어그램 - 가운데 createSearchLogger 함수에 track 소켓이 있고, 실제 앱에서는 진짜 SDK 플러그가, 테스트에서는 vi.fn 플러그가 꽂힙니다. 아래 두 장면은 실제 테스트 결과로, 공백뿐인 검색어에서는 호출 기록부가 텅 비어 있고, 공백 낀 alice 입력에서는 다듬어진 인자가 기록부에 남아 trim이 일했다는 증거가 됩니다" >}}

여기서 설계 하나를 눈여겨보세요. `track`(실제로 이벤트를 보내는 함수)을 **밖에서 인자로 받고 있죠.** 실제 앱에서는 진짜 SDK 함수를 넣어주면 되고, 테스트에서는 — 대역을 넣으면 됩니다. 이런 설계를 **의존성 주입**이라고 부르는데, 거창한 이름과 달리 핵심은 "필요한 걸 안에서 만들지 말고 밖에서 받자"가 전부예요. 이 한 끗이 테스트 난이도를 하늘과 땅으로 가릅니다.

테스트는 이렇게 씁니다. `src/lib/analytics.test.ts`:

```ts
import { describe, it, expect, vi } from 'vitest'
import { createSearchLogger } from './analytics'

describe('createSearchLogger', () => {
  it('빈 검색어는 기록하지 않는다', () => {
    const track = vi.fn()
    const log = createSearchLogger(track)

    log('   ')

    expect(track).not.toHaveBeenCalled()
  })

  it('검색어를 다듬어 기록한다', () => {
    const track = vi.fn()
    const log = createSearchLogger(track)

    log('  alice ')

    expect(track).toHaveBeenCalledWith('search', { query: 'alice' })
  })
})
```

두 번째 테스트를 뜯어보면 — `'  alice '`(공백 포함)를 넣고, `{ query: 'alice' }`(다듬어진 값)로 불렸는지 확인합니다. **"불렸다"만이 아니라 "정확히 어떤 인자로 불렸다"까지** 검증하는 거죠. 진짜 SDK는 근처에도 안 왔는데, 로거의 동작은 빈틈없이 확인됐습니다. 4편의 경계값 질문("입력이 없다면?")이 첫 테스트에 그대로 적용된 것도 보이시죠?

### 주입할 수 없는 상대라면 — vi.spyOn

항상 인자로 주입받게 설계할 수 있으면 좋겠지만, 현실엔 이미 존재하는 객체의 메서드를 감시해야 할 때도 있어요. 그럴 땐 `vi.spyOn`입니다.

```ts
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})

// ... console.warn을 부르는 코드 실행 ...
expect(spy).toHaveBeenCalledWith('deprecated option')

spy.mockRestore()   // 반드시 원상 복구!
```

`vi.spyOn(객체, '메서드명')`은 기존 메서드를 몰래 스파이로 바꿔치기합니다. `mockImplementation(() => {})`은 "기록만 하고 실제 동작(콘솔 출력)은 하지 마라"는 뜻이고요. 끝나면 `mockRestore()`로 되돌리는 것까지가 한 세트예요 — 지난 편의 `useRealTimers`와 같은 원칙입니다. **빌린 세계는 반드시 원상 복구.**

---

## 일부러 부숴봅시다

이번 편의 경보기 점검. `analytics.ts`에서 `trim()`을 지워보세요.

```ts
const q = query   // .trim() 삭제!
```

저장하면 테스트 **두 개가 동시에** 빨간불이 됩니다.

```bash
 × 빈 검색어는 기록하지 않는다
   → expected "spy" to not be called at all, but actually been called 1 times
 × 검색어를 다듬어 기록한다
   → expected "spy" to be called with arguments: [ 'search', { query: 'alice' } ]
     Received: [ 'search', { query: '  alice ' } ]
```

{{< img src="images/contents/break-on-purpose.png" alt="trim을 삭제해 테스트를 일부러 깨뜨린 장면 - 위쪽에는 analytics.ts에서 .trim()을 지운 코드와 그로 인해 두 테스트가 동시에 빨간불이 된 모습, 아래쪽에는 두 실패 리포트가 나란히 있습니다. 왼쪽은 안 불려야 하는데 1번 불렸다는 메시지, 오른쪽은 기대한 인자 query alice와 공백이 남은 실제 인자를 대조해 보여줍니다" >}}

실패 메시지가 꽤 친절하죠. 첫 번째는 "안 불려야 하는데 1번 불렸다", 두 번째는 "기대한 인자와 실제 인자"를 나란히 보여줍니다. `vi.fn()`이 모든 호출을 기억하고 있었기에 가능한 리포트예요. `trim()`을 되돌리면 다시 초록불입니다.

---

## 과한 모킹은 독

대역이 편하다고 모든 걸 가짜로 바꾸기 시작하면, 어느 순간 **구현을 테스트하게 됩니다.** "A가 B를 부르고 B가 C를 부른다"를 전부 mock으로 검증하는 테스트는, 동작이 멀쩡해도 리팩터링만 하면 와르르 깨져요. 그건 안전망이 아니라 코드를 못 움직이게 하는 족쇄입니다.

원칙은 이렇게 잡으세요. **가짜로 바꾸는 건 경계뿐** — 네트워크, 시간, 랜덤, 외부 SDK처럼 통제할 수 없는 것들. 내가 짠 로직끼리는 진짜로 붙여서 테스트합니다. 오늘 로거에서도 대역은 외부 SDK 자리(`track`) 하나뿐이었죠. 네트워크 경계는 다음 챕터에서 MSW라는 전문가가 맡습니다.

---

## 한 장 요약

- **테스트 더블** = 위험하거나 통제 불가능한 의존성 대신 세우는 대역의 총칭
- 구분법: **stub**은 입력을 채우고(정해진 값 반환), **spy·mock**은 출력을 확인하고(호출 기록·검증), **fake**는 작게 새로 짓는다
- Vitest에선 `vi.fn()` 하나가 셋을 겸함 — 호출 횟수·인자를 전부 기억하고 `toHaveBeenCalledWith`로 꺼내 봄
- 의존성은 안에서 만들지 말고 **밖에서 받자**(주입) — 이 설계 하나가 테스트 난이도를 가름
- 가짜로 바꾸는 건 **경계만**(네트워크·시간·랜덤·외부 SDK) — 전부 모킹하면 리팩터링을 막는 족쇄가 됨

---

## 정리하며

이것으로 1장 — 단위 테스트의 기초 체력 훈련이 끝났습니다. 순수 함수(4편), 비동기와 시간(5편), 그리고 오늘 대역까지. 함수 단위에서 나올 수 있는 상대는 이제 웬만하면 다 만나봤어요. 다음 챕터부터는 무대를 화면으로 옮겨서, 컴포넌트를 "사용자처럼" 테스트합니다.

테스트를 통과시키려고 모킹을 하나둘 늘리다 보면, 어느새 "잠깐, 나 지금 내가 짠 mock을 테스트하고 있네?" 하는 순간이 옵니다. 그때가 멈출 때예요.

> **레벨업**: mock과 spy를 구분해서 '딱 필요한 만큼만' 가짜로 바꿀 수 있습니다.

> **다음 편**: Testing Library 철학 — 사용자처럼 쿼리하기

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

