「프론트엔드 테스트 제대로 하기」 시리즈의 6편입니다. 전체 목차 보기 · 용어집

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

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

실습 코드: 이 편의 상태가 step-06 태그에 그대로 담겨 있습니다. git checkout step-06으로 따라오세요. 지난 편과의 차이만 보고 싶다면 step-05와 비교도 됩니다.

이 질문들에 답해봅니다

  • stub·spy·mock·fake는 뭐가 다른가
  • 무엇을, 어디까지 가짜로 바꿀 것인가
  • 과한 모킹은 왜 독인가
스턴트 대역에 비유한 테스트 더블 4종 비교 다이어그램 - stub은 정해진 값만 돌려주는 대역, spy는 호출을 기록하는 관찰 카메라, mock은 호출 방식까지 검증하는 대역, fake는 간단히 새로 지은 대체 세트장으로 각각 한 줄 설명과 함께 카드로 나란히 배치되어 있습니다
스턴트 대역에 비유한 테스트 더블 4종 비교 다이어그램 - stub은 정해진 값만 돌려주는 대역, spy는 호출을 기록하는 관찰 카메라, mock은 호출 방식까지 검증하는 대역, fake는 간단히 새로 지은 대체 세트장으로 각각 한 줄 설명과 함께 카드로 나란히 배치되어 있습니다

왜 대역이 필요한가

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

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

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

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


대역에도 종류가 있다

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

데이터 표
이름하는 일이럴 때
stub정해진 값만 돌려준다“API가 이 데이터를 준다고 치자”
spy호출 여부·횟수·인자를 기록한다“이 함수가 제대로 불렸나 확인하자”
mockspy + “이렇게 불려야 한다"는 기대까지 검증“정확히 이 인자로 한 번 불려야 해”
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 같은 매처로 꺼내 보는 거예요.


넷을 하나씩, 손에 잡히게

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

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

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

ts
const loadUsers = vi.fn().mockReturnValue([
  { id: '1', name: 'Alice', email: '[email protected]' },
])

// 이제 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 })
  }
}
의존성 주입을 소켓과 플러그로 표현한 다이어그램 - 가운데 createSearchLogger 함수에 track 소켓이 있고, 실제 앱에서는 진짜 SDK 플러그가, 테스트에서는 vi.fn 플러그가 꽂힙니다. 아래 두 장면은 실제 테스트 결과로, 공백뿐인 검색어에서는 호출 기록부가 텅 비어 있고, 공백 낀 alice 입력에서는 다듬어진 인자가 기록부에 남아 trim이 일했다는 증거가 됩니다
의존성 주입을 소켓과 플러그로 표현한 다이어그램 - 가운데 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 ' } ]
trim을 삭제해 테스트를 일부러 깨뜨린 장면 - 위쪽에는 analytics.ts에서 .trim()을 지운 코드와 그로 인해 두 테스트가 동시에 빨간불이 된 모습, 아래쪽에는 두 실패 리포트가 나란히 있습니다. 왼쪽은 안 불려야 하는데 1번 불렸다는 메시지, 오른쪽은 기대한 인자 query alice와 공백이 남은 실제 인자를 대조해 보여줍니다
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 철학 — 사용자처럼 쿼리하기

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.