# 비동기·타이머·에러를 테스트하는 법

> Promise, setTimeout, 그리고 실패 케이스. Vitest의 resolves·rejects 매처와 가짜 타이머(fake timers)로, 프론트엔드 비동기 코드를 안정적으로 테스트하는 방법을 다룹니다.

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

---



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

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

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

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

## 이번 편의 목표

- async/await로 Promise를 검증하기 — `resolves`와 `rejects`
- 가짜 타이머(fake timers)로 시간을 조작하기
- 성공만큼 중요한 실패 케이스 테스트

{{< img src="images/contents/async-timeline.png" alt="실제 시간과 가짜 시간을 비교하는 타임라인 다이어그램 - 위쪽 실제 시간 줄에서는 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에서 실행된다"를 확인합니다 — 어디서 본 패턴이죠? [지난 편의 경계값 테스트](/posts/frontend-testing-unit-basics/)가 시간 축에서 그대로 재현된 겁니다. 실제로 기다렸다면 수백 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 — 테스트 더블 제대로 이해하기

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

