컴포넌트가 API를 부르기 시작하면 테스트가 흔들립니다. 서버가 느리면 같이 느려지고, 데이터가 바뀌면 깨지고, 오프라인이면 전멸이죠. MSW(Mock Service Worker) 는 네트워크 요청을 경계에서 가로채 준비된 응답을 돌려주는 도구로, 이 문제를 뿌리에서 해결합니다. 이 글 하나로 설치·핸들러·성공과 실패 시뮬레이션까지 정리해요.
시리즈로 따라오셨다면 — 비동기 편부터 “일단 복사해서” 쓰던 src/mocks/ 폴더의 정체를 오늘 밝힙니다. 검색으로 처음 오셨어도 괜찮아요. 예제는 자급자족하고, Vitest 기준이지만 Jest 환경에서도 MSW 사용법은 동일합니다.
실습 코드: 이 편의 상태가
step-09태그에 담겨 있습니다(코드는 지난 편과 동일 — 이 편은 이미 있던src/mocks/의 원리를 밝히는 편이에요). StackBlitz에서 바로 열기도 됩니다.
오늘 다루는 것#
- MSW 핸들러·서버 셋업
- 성공·실패 응답 시뮬레이션
- fetch를 직접 모킹하지 않는 이유

MSW를 한마디로 하면 아파트 경비실입니다. 컴포넌트가 보낸 요청(택배 기사)이 현관을 나서기 직전, 경비실이 가로채서 미리 맡아둔 상자(준비된 응답)를 대신 건네줘요. 중요한 건 — 컴포넌트는 이 사실을 전혀 모릅니다. 진짜 서버와 통신한 줄로만 알아요. fetch 코드도, 에러 처리도, JSON 파싱도 전부 진짜처럼 돌아갑니다. 가짜인 건 딱 하나, 회선 너머뿐이에요.
“어? 테스트 더블 편에서 배운 vi.fn()으로 fetchUsers를 통째로 바꾸면 되지 않나?” 싶으실 수 있어요. 좋은 감각인데, 결정적 차이가 있습니다 — 그 방법은 fetchUsers 안의 코드가 아예 실행되지 않아요. URL이 틀려도, 에러 처리가 빠져도 모릅니다. MSW는 함수를 끝까지 진짜로 돌리고 네트워크만 가짜로 바꿔요. 어느 쪽을 얼마나 가짜로 할지의 기준은 잠시 뒤에 다시 정리합니다.
핸들러와 서버#
먼저 버전부터: 이 글은 MSW v2 기준입니다(npm i -D msw@latest). v1과는 import 경로와 API가 달라서, 예전 자료를 섞어 보면 헤매기 쉬워요.
요청을 가로채는 규칙(핸들러)을 정의하고, 테스트용 Node 서버로 띄웁니다. (repo src/mocks/)
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const users = [
{ id: '1', name: 'Alice Kim', email: '[email protected]' },
{ id: '2', name: 'Bob Lee', email: '[email protected]' },
{ id: '3', name: 'Carol Park', email: '[email protected]' },
]
export const handlers = [
// GET /api/users 요청이 오면 → 위 목록을 JSON으로 응답
http.get('/api/users', () => HttpResponse.json(users)),
]읽는 법은 단순합니다. http.get(경로, 리졸버) — “이 경로로 GET 요청이 오면, 이 함수(리졸버)가 응답을 만든다.” HttpResponse.json(users)는 그 응답을 JSON으로 빚는 도우미고요. 핸들러 배열에 규칙을 쌓아두면, 그게 곧 우리 가짜 서버의 API 명세가 됩니다.
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// 핸들러 규칙으로 무장한 테스트(Node)용 목 서버
export const server = setupServer(...handlers)그리고 셋업 파일에 라이프사이클을 연결합니다. 환경 세팅 편에서 만든 setup.ts가 여기서 완성돼요.
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from '../mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) // 테스트 간 오염 방지
afterAll(() => server.close())세 줄이 각자 맡은 일이 있습니다. listen은 테스트 파일이 시작될 때 경비실에 출근 도장을 찍는 것이고, close는 다 끝나면 퇴근. 가운데 resetHandlers는 매 테스트가 끝날 때마다 핸들러를 처음 상태로 되돌립니다 — 이게 왜 중요한지는 바로 다음 섹션에서 보게 돼요. onUnhandledRequest: 'error'는 “등록 안 된 방문자는 통과 금지” 옵션입니다. 핸들러 없는 요청이 몰래 나가면 테스트를 즉시 실패시켜서, “어? 이 컴포넌트가 이런 API도 불렀어?“를 그 자리에서 알려줘요.
성공 경로 — 핸들러가 있으니 그냥 됩니다#
이 핸들러 위에서 컴포넌트 테스트가 어떻게 되는지부터 볼게요. 무대는 지난 편에서 서버 연동으로 업그레이드한 검색 컴포넌트 UserSearch입니다 — 마운트되면 /api/users에서 목록을 불러와 그려요. 처음 오셨다면 “fetch로 목록을 받아 보여주는 평범한 컴포넌트"라고 생각하시면 충분합니다.
it('사용자 목록을 불러와 보여준다', async () => {
render(<UserSearch />)
// MSW가 돌려준 목록이 화면에 그려질 때까지 기다렸다가 검증
expect(await screen.findByText('Alice Kim')).toBeInTheDocument()
})테스트 어디에도 MSW가 안 보이죠? 그게 포인트입니다. 컴포넌트가 /api/users를 부르면 경비실이 등록된 규칙대로 Alice·Bob·Carol을 돌려주고, 테스트는 화면에 그려진 결과만 검증해요. 네트워크 사정이라는 변수가 사라진, 언제 돌려도 같은 테스트가 됐습니다.
실패도 손쉽게#
기억하실지 모르겠어요 — 지난 편에서 에러 상태(role="alert") 테스트를 “다음 편의 특기"라며 미뤄뒀었죠. 바로 지금입니다. 한 테스트에서만 서버 에러를 흉내 내려면 핸들러를 덮어씁니다. (repo 실제 테스트)
it('서버 오류 시 role="alert"로 알린다', async () => {
// 이 테스트에서만 /api/users를 500으로 덮어쓰기 (끝나면 자동 복구)
server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
render(<UserSearch />)
expect(await screen.findByRole('alert')).toHaveTextContent('불러오지 못했습니다')
})server.use(...)는 기존 규칙 위에 임시 규칙을 한 장 덮는 것이에요. 그리고 아까 셋업의 resetHandlers()가 매 테스트 후 그 임시 규칙을 걷어냅니다 — 그래서 이 500 응답은 이 테스트에서만 유효하고, 다음 테스트는 다시 정상 응답을 받아요. 진짜 서버라면 “잠깐 500 좀 내주세요"라고 부탁할 수도 없는 노릇인데, 경비실은 부탁 한 줄이면 들어줍니다.

왜 fetch 모킹 대신 MSW#
vi.spyOn(global, 'fetch')는 구현에 묶여요. 내일 누가 fetch를 axios로 바꾸면, 동작은 그대로인데 테스트가 우수수 깨집니다 — 철학 편에서 경계했던 ‘구현 테스트’가 네트워크에서 재현되는 거죠. 그리고 앞서 말했듯 fetchUsers를 통째로 대역으로 바꾸면 그 함수 속 코드는 검증 밖입니다. MSW는 네트워크 경계에서 가로채니, 코드가 axios든 fetch든 React Query든 테스트가 그대로고, 내 코드는 마지막 한 줄까지 진짜로 돕니다.

정리하면 기준은 이렇습니다 — 내 코드는 진짜로, 회선 너머만 가짜로. 테스트 더블 편의 “경계만 가짜로” 원칙이 네트워크에서 완성되는 셈이에요.
게다가 같은 핸들러로 개발 중 브라우저 목업(src/mocks/browser.ts + npx msw init public)도 돌릴 수 있어요. 한 번 만들고 두 번 우려먹기.
아니, 사실 세 번입니다. 이 시리즈의 라이브 데모는 백엔드가 아예 없는 GitHub Pages인데도 검색이 동작하죠 — 배포판에서 같은 MSW 핸들러가 /api/users를 응답하고 있거든요. 테스트용으로 만든 목이 데모 서버 역할까지 하는 셈이에요.
한 장 요약#
- MSW = 네트워크 경계에서 요청을 가로채 가짜 응답을 주는 도구 — 코드는 진짜 서버와 대화하는 줄 모름
- 구성 3종: 핸들러(요청→응답 규칙) + server(테스트용 Node) + 셋업 파일의 라이프사이클(listen/resetHandlers/close)
- 실패 시뮬레이션은
server.use(...)한 줄 — 그 테스트에서만 유효, 끝나면 자동 복구 - 기준은 “내 코드는 진짜로, 회선 너머만 가짜로” — fetch를 직접 모킹하면 구현이 바뀔 때 테스트도 깨집니다
onUnhandledRequest: 'error'로 몰래 나가는 요청까지 감시, 같은 핸들러를 개발·데모용으로 재활용
네트워크는 이제 손안에 있습니다#
이제 안정적인 컴포넌트 테스트 기반이 갖춰졌어요. 다음 편은 이 챕터의 하이라이트 — 접근성을 테스트에 녹입니다.
오늘 만든 경비실은 이 시리즈가 끝날 때까지 계속 근무합니다. 앞으로 어떤 컴포넌트가 어떤 API를 부르든, 핸들러 한 장 추가하면 성공이든 실패든 원하는 장면을 만들 수 있어요.
레벨업: 네트워크를 격리해 성공·실패 시나리오를 마음대로 시뮬레이션할 수 있습니다.
다음 편: 접근성을 테스트에 녹이기 — role 기반 쿼리
낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.
