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

현실의 코드는 기다립니다. API 응답을 기다리고, 타이머를 기다리죠. 그래서 비동기 테스트는 프론트엔드에서 가장 먼저 부딪히는 벽인데, 어렵게 느껴지는 이유는 사실 딱 하나예요 — “언제 검증하느냐”. 답이 도착하기 전에 채점하면, 빈 답안지를 채점하는 셈이 되거든요. 이 글 하나로 Promise 검증(resolves/rejects), 가짜 타이머, 실패 케이스까지 — 기다리는 코드를 다루는 무기를 전부 챙깁니다.

시리즈로 따라오고 계셨다면, 지난 편filterByQuery(부르면 그 자리에서 답이 나오던 순수 함수)와 오늘의 상대가 어떻게 다른지 바로 느껴지실 거예요. 검색으로 처음 오셨어도 괜찮습니다 — 예제는 이 글 안에서 자급자족해요. Vitest 기준이지만 Jest에서도 vijest로 바꾸면 거의 그대로 동작합니다. (환경이 없다면 세팅 편 참고.)

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

이번 편의 목표

  • async/await로 Promise를 검증하기 — resolvesrejects
  • 가짜 타이머(fake timers)로 시간을 조작하기
  • 성공만큼 중요한 실패 케이스 테스트
실제 시간과 가짜 시간을 비교하는 타임라인 다이어그램 - 위쪽 실제 시간 줄에서는 300ms를 그대로 기다려 테스트가 느려지지만, 아래쪽 가짜 타이머 줄에서는 advanceTimersByTime으로 시간을 즉시 밀어 밀리초 만에 검증이 끝나는 흐름이 그려져 있습니다
실제 시간과 가짜 시간을 비교하는 타임라인 다이어그램 - 위쪽 실제 시간 줄에서는 300ms를 그대로 기다려 테스트가 느려지지만, 아래쪽 가짜 타이머 줄에서는 advanceTimersByTime으로 시간을 즉시 밀어 밀리초 만에 검증이 끝나는 흐름이 그려져 있습니다

빈 답안지를 채점하지 않으려면

비동기 테스트에서 가장 흔한 사고를 먼저 보여드릴게요. await를 빼먹은 테스트입니다.

ts
it('사용자 목록을 가져온다', () => {
  expect(fetchUsers()).resolves.toHaveLength(3)   // await가 없다!
})

이 테스트는 항상 통과합니다. 서버가 죽어 있어도요. 검증이 끝나기를 기다리지 않고 테스트가 먼저 끝나버리기 때문이에요. 초록불인데 아무것도 지켜주지 않는, 2편에서 봤던 “가짜 초록불"의 비동기 버전입니다. Vitest가 경고를 띄워주긴 하지만, 원칙으로 기억하는 게 안전해요.

비동기 검증 앞에는 반드시 await. 오늘 배우는 모든 패턴의 제1원칙입니다.


fetchUsers 만들기

지난 편의 src/lib/users.ts에 서버에서 사용자 목록을 가져오는 함수를 추가합니다.

ts
// src/lib/users.ts 에 이어서
export async function fetchUsers(): Promise<User[]> {
  const res = await fetch('/api/users')
  if (!res.ok) throw new Error('failed to fetch users')
  return (await res.json()) as User[]
}

fetch로 요청을 보내고, 응답이 정상(res.ok)이 아니면 에러를 던지고, 정상이면 JSON을 풀어서 돌려줍니다. 전형적인 API 호출 함수죠.

그런데 테스트에서 이걸 부르면 진짜 서버가 필요합니다. 테스트가 서버 상태에 따라 됐다 안 됐다 하면 곤란하니, 네트워크 요청을 가로채 가짜 응답을 주는 도구 — MSW를 살짝 미리 당겨서 씁니다. 아직 안 배운 도구지만 괜찮아요. 지금은 “테스트 중에는 /api/users 요청에 준비된 답이 온다"는 것만 알면 충분하고, 제대로 된 설명은 컴포넌트 챕터에서 합니다.

따라 하는 중이라면 repo의 src/mocks/ 폴더(파일 3개)를 그대로 복사하고, src/test/setup.ts를 repo 버전으로 맞춰주세요. 서버 흉내에 필요한 준비가 그게 전부입니다.


성공과 실패를 못 박기

이제 users.test.ts에 fetchUsers 테스트를 추가합니다. Promise 검증엔 전용 매처가 있어요 — 성공은 resolves, 실패는 rejects.

ts
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { fetchUsers } from './users'

describe('fetchUsers', () => {
  it('사용자 목록을 가져온다', async () => {
    await expect(fetchUsers()).resolves.toHaveLength(3)
  })

  it('서버 오류면 에러를 던진다', async () => {
    server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
    await expect(fetchUsers()).rejects.toThrow('failed to fetch users')
  })
})

읽는 법을 풀어보면:

  • await expect(프로미스).resolves.매처() — “이 Promise가 성공하고, 그 결과가 이래야 한다”. 성공을 기다렸다가 결과를 검증합니다.
  • await expect(프로미스).rejects.toThrow(...) — “이 Promise가 실패하고, 이 에러를 던져야 한다”. 거꾸로 실패를 기다리는 거예요. 만약 성공해버리면 테스트가 실패합니다.
  • 두 번째 테스트의 server.use(...)는 “이 테스트에서만 /api/users가 500을 돌려주게 하라"는 일회용 오버라이드입니다. 테스트가 끝나면 원래 응답으로 자동 복구돼요. 실패 상황을 이렇게 마음대로 만들 수 있는 게 가짜 서버의 힘입니다.

성공만 테스트하면 절반입니다. 네트워크 실패, 빈 응답, 서버 오류는 사용자가 실제로 만나는 상황이에요. “에러가 나면 에러답게 죽는다"까지 못 박아둬야, 나중에 에러 처리 코드를 누가 지워도 테스트가 잡아냅니다.


가짜 타이머 — 시간을 밀어버리기

이번엔 다른 종류의 기다림입니다. 검색창에 타이핑할 때마다 API를 부르면 낭비니까, “입력이 멈추고 300ms 지나면 한 번만” 실행하는 디바운스(debounce) 를 만들어볼게요. src/lib/debounce.ts:

ts
/** 마지막 호출 후 ms가 지나면 한 번만 실행 */
export function debounce<T extends (...args: never[]) => void>(fn: T, ms: number) {
  let timer: ReturnType<typeof setTimeout> | undefined
  return (...args: Parameters<T>) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), ms)
  }
}

호출될 때마다 이전 타이머를 취소하고 새로 300ms를 재기 시작합니다. 그래서 연속 호출하면 마지막 것만 살아남죠. (타입의 never[]가 궁금하다면 repo 주석에 설명이 있어요 — 지금 몰라도 전혀 지장 없습니다.)

이걸 테스트하려고 진짜로 300ms를 기다리면 두 가지가 망가집니다. 테스트가 느려지고(수백 개면 분 단위), 기계 상태에 따라 됐다 안 됐다(flaky) 하게 돼요. 그래서 기다리는 대신, 시간을 우리 손으로 조작합니다.

ts
describe('debounce', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

  it('연속 호출 시 마지막 한 번만 실행된다', () => {
    const spy = vi.fn()
    const run = debounce(spy, 300)

    run(); run(); run()

    vi.advanceTimersByTime(299)
    expect(spy).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1)
    expect(spy).toHaveBeenCalledOnce()
  })
})

처음 보는 것들을 정리하면:

  • vi — Vitest가 기본 제공하는 도우미 객체입니다. 가짜 타이머, 가짜 함수 같은 도구가 여기 들어 있어요.
  • beforeEach / afterEach — 각 테스트의 앞뒤에 자동으로 실행되는 훅입니다. 여기선 “시작 전에 시간을 가짜로 바꾸고, 끝나면 반드시 원상 복구"를 보장해요. 복구를 빼먹으면 다른 테스트까지 가짜 시간 속에서 돌게 되니, 이 한 쌍은 세트로 기억하세요.
  • vi.fn() — 호출 여부를 기록하는 가짜 함수입니다(‘스파이’라고 불러요). “몇 번 불렸나"를 물어볼 수 있어서 디바운스 검증에 딱이죠. 다음 편의 주인공이니 오늘은 이 정도만.
  • vi.advanceTimersByTime(299) — 시간을 299ms 밀어버립니다. 기다리는 게 아니라 순간이동이에요.

299에서 멈춰 “아직은 아니다"를 확인하고, 1을 더 밀어 “정확히 300에서 실행된다"를 확인합니다 — 어디서 본 패턴이죠? 지난 편의 경계값 테스트가 시간 축에서 그대로 재현된 겁니다. 실제로 기다렸다면 수백 ms짜리였을 테스트가, 밀리초 만에 끝납니다.


일부러 부숴봅시다

이번 편의 경보기 점검입니다. 첫 번째 fetchUsers 테스트에서 await를 지워보세요.

ts
it('사용자 목록을 가져온다', () => {
  expect(fetchUsers()).resolves.toHaveLength(3)   // await 삭제!
})

그리고 일부러 고장을 냅니다 — users.ts에서 URL을 /api/userz로 바꿔보세요. 서버가 404를 돌려줄 테니 테스트는 당연히 빨간불이어야 하는데… 초록불이 켜집니다(Vitest가 “처리되지 않은 rejection” 경고를 내긴 하지만, 테스트 자체는 통과로 기록돼요). await를 되돌리면 그제야 정직한 빨간불이 뜹니다.

URL을 원래대로 복구하는 것, 잊지 마세요. 오늘의 교훈 한 줄: 초록불이 반가울수록, 그 테스트가 정말 기다렸는지 의심하세요.


한 장 요약

  • 비동기 테스트의 전부는 “언제 검증하느냐” — 비동기 검증 앞에는 반드시 await (없으면 항상 통과하는 가짜 초록불)
  • Promise 성공은 await expect(p).resolves.매처(), 실패는 rejects.toThrow(...) — 실패 케이스도 절반의 몫
  • 시간에 의존하는 코드는 기다리지 말고 가짜 타이머로 밀 것vi.useFakeTimers() + advanceTimersByTime()
  • beforeEach/afterEach로 가짜 세계는 반드시 원상 복구
  • 다 썼으면 await를 지워보고 거짓 통과를 직접 목격할 것 — 그래야 안 잊습니다

비동기, 이제 안 무섭습니다

기다리는 코드를 검증하는 무기 세 개가 생겼습니다. await + resolves/rejects, 그리고 가짜 타이머. 다음은 의존성을 갈아 끼우는 기술 — 오늘 살짝 등장한 vi.fn()의 정체를 포함해, 테스트 더블을 제대로 배웁니다.

비동기 테스트가 가끔 통과하고 가끔 실패한다면? 축하합니다, 1편에서 예고한 flaky 테스트를 벌써 만났네요. 범인은 대개 빠뜨린 await입니다.

레벨업: 비동기·타이머·실패 케이스까지 겁내지 않고 테스트할 수 있습니다.

다음 편: mock·stub·spy — 테스트 더블 제대로 이해하기

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