Prologue: 모킹이 실제 API와 달랐다
테스트 환경에서는 완벽하게 통과했다. 스테이징에서도 괜찮았다. 프로덕션에서 API 응답 구조가 바뀌었다는 걸 두 달 만에 알았다. 테스트가 실제 API 대신 오래된 모킹 데이터를 쓰고 있었기 때문이다.
이 문제의 근본 원인은 모킹 방식에 있었다. vi.mock('axios')나 global.fetch = jest.fn()처럼 라이브러리 레벨에서 모킹하면, 실제 네트워크 요청이 아예 발생하지 않는다. 컴포넌트가 fetch를 쓰는지, axios를 쓰는지에 따라 모킹 방법이 달라지는 것도 문제다.
MSW(Mock Service Worker)는 이 문제를 다른 방식으로 풀었다. 네트워크 레벨에서 요청을 인터셉트한다. 컴포넌트는 진짜 HTTP 요청을 보내고, MSW가 중간에서 가로채서 가짜 응답을 돌려준다. 어떤 HTTP 클라이언트를 써도 동일하게 동작한다.
MSW가 다른 모킹 방법과 다른 점
기존 방법들의 문제
// 방법 1: fetch 직접 모킹
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ users: [] }),
})
// 문제: fetch 전체가 교체됨, 다른 URL 요청도 영향받음
// 문제: Response 객체의 실제 동작 (headers, status 등) 안 됨
// 방법 2: axios 모킹
vi.mock('axios')
vi.mocked(axios.get).mockResolvedValue({ data: { users: [] } })
// 문제: axios를 안 쓰면 다시 짜야 함
// 문제: 테스트 코드가 HTTP 클라이언트 구현에 묶임
// 방법 3: API 함수 직접 모킹
vi.mock('./api/users')
vi.mocked(getUsers).mockResolvedValue([])
// 문제: 네트워크 레이어 전체를 건너뜀
// 문제: 실제 API 호출 코드가 테스트되지 않음
MSW의 접근법
컴포넌트 → fetch('/api/users') → [브라우저 Service Worker / Node.js 인터셉터]
↓
MSW 핸들러
↓
가짜 응답 반환
컴포넌트는 진짜 HTTP 요청을 그대로 보낸다. 네트워크 레이어에서 인터셉트하므로:
- 어떤 HTTP 클라이언트를 쓰든 동일하게 동작 (fetch, axios, ky, got...)
- 요청 헤더, 쿼리 파라미터, 바디 파싱이 실제처럼 동작
- 네트워크 에러, 타임아웃 시뮬레이션 가능
- 브라우저 개발자 도구 네트워크 탭에서도 보임 (브라우저 모드)
설치 및 기본 설정
npm install msw --save-dev
MSW 2.x 기준으로 설명한다.
핸들러 정의
핸들러는 브라우저와 Node.js 환경에서 공유된다:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET 요청
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: '김개발', email: 'dev@example.com', role: 'admin' },
{ id: '2', name: '이프론트', email: 'front@example.com', role: 'user' },
])
}),
// 동적 URL 파라미터
http.get('/api/users/:id', ({ params }) => {
const { id } = params
if (id === '999') {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return HttpResponse.json({
id,
name: '김개발',
email: 'dev@example.com',
})
}),
// POST 요청 — 요청 바디 읽기
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json(
{
id: String(Date.now()),
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
)
}),
// PUT 요청
http.put('/api/users/:id', async ({ params, request }) => {
const { id } = params
const body = await request.json() as Partial<{ name: string; email: string }>
return HttpResponse.json({
id,
...body,
updatedAt: new Date().toISOString(),
})
}),
// DELETE 요청
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 })
}),
]
브라우저 설정
# Service Worker 파일 생성
npx msw init public/ --save
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
// src/main.tsx (또는 진입점)
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start({
onUnhandledRequest: 'warn', // 핸들러 없는 요청 경고
})
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})
Node.js (테스트 환경) 설정
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from '../mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) // 각 테스트 후 핸들러 초기화
afterAll(() => server.close())
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})
실전 테스트 예제
기본 데이터 로딩 테스트
// src/components/UserList.test.tsx
import { vi, describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { UserList } from './UserList'
describe('UserList', () => {
it('유저 목록을 불러와 표시한다', async () => {
render(<UserList />)
// 로딩 상태 확인
expect(screen.getByRole('status')).toHaveTextContent('로딩 중...')
// 데이터 로드 완료 대기
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
// 기본 핸들러에서 반환한 유저들이 표시되는지 확인
expect(screen.getByText('김개발')).toBeInTheDocument()
expect(screen.getByText('이프론트')).toBeInTheDocument()
})
})
특정 테스트에서 핸들러 오버라이드
기본 핸들러를 유지하면서 특정 테스트에서만 다른 응답을 반환하고 싶을 때:
// src/components/UserList.test.tsx
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
describe('UserList 에러 케이스', () => {
it('API 에러 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 에러 응답 반환
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('데이터를 불러올 수 없습니다')
})
})
it('빈 목록일 때 안내 메시지를 표시한다', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([])
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('등록된 사용자가 없습니다')).toBeInTheDocument()
})
})
})
// server.resetHandlers()가 afterEach에서 자동 실행돼 기본 핸들러로 복귀
요청 검증
MSW 핸들러에서 요청이 올바른 데이터를 보내는지도 검증할 수 있다:
import { vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
describe('CreateUser 폼', () => {
it('폼 제출 시 올바른 데이터로 API 호출', async () => {
const user = userEvent.setup()
const requestSpy = vi.fn()
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json()
requestSpy(body)
return HttpResponse.json({ id: '123', ...(body as object) }, { status: 201 })
})
)
render(<CreateUserForm />)
await user.type(screen.getByLabelText('이름'), '박개발')
await user.type(screen.getByLabelText('이메일'), 'newdev@example.com')
await user.click(screen.getByRole('button', { name: '생성' }))
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith({
name: '박개발',
email: 'newdev@example.com',
})
})
})
})
응답 지연과 네트워크 에러 시뮬레이션
import { http, HttpResponse, delay } from 'msw'
export const handlers = [
// 응답 지연
http.get('/api/slow-data', async () => {
await delay(2000) // 2초 지연
return HttpResponse.json({ data: 'slow response' })
}),
// 네트워크 에러 (연결 실패)
http.get('/api/unreachable', () => {
return HttpResponse.error()
}),
// 실제 네트워크 지연 패턴
http.get('/api/realistic', async () => {
await delay('real') // 실제 네트워크 지연 시뮬레이션
return HttpResponse.json({ data: 'ok' })
}),
]
테스트에서 로딩 UI를 테스트할 때 유용하다:
it('로딩 중에는 스켈레톤 UI를 표시한다', async () => {
server.use(
http.get('/api/users', async () => {
await delay(500)
return HttpResponse.json([])
})
)
render(<UserList />)
// 로딩 중 스켈레톤이 보이는지
expect(screen.getAllByTestId('skeleton-item')).toHaveLength(3)
// 로딩 완료 후 스켈레톤이 사라지는지
await waitFor(() => {
expect(screen.queryByTestId('skeleton-item')).not.toBeInTheDocument()
})
})
REST API 외: GraphQL 핸들러
import { graphql, HttpResponse } from 'msw'
export const handlers = [
graphql.query('GetUser', ({ variables }) => {
const { id } = variables
return HttpResponse.json({
data: {
user: {
id,
name: '김개발',
email: 'dev@example.com',
},
},
})
}),
graphql.mutation('UpdateUser', ({ variables }) => {
return HttpResponse.json({
data: {
updateUser: {
...variables.input,
updatedAt: new Date().toISOString(),
},
},
})
}),
// 에러 응답
graphql.query('GetDeletedUser', () => {
return HttpResponse.json({
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
})
}),
]
Storybook 통합
MSW는 Storybook과 통합하면 컴포넌트 개발 시 실제 API 없이도 모든 상태를 시각화할 수 있다.
npm install msw-storybook-addon --save-dev
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize()
export const loaders = [mswLoader]
// components/UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { http, HttpResponse } from 'msw'
import { UserProfile } from './UserProfile'
const meta: Meta<typeof UserProfile> = {
component: UserProfile,
}
export default meta
type Story = StoryObj<typeof UserProfile>
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json({
id: '1',
name: '김개발',
email: 'dev@example.com',
avatar: '/avatar.jpg',
bio: '프론트엔드 개발자',
})
}),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', async () => {
await new Promise(() => {}) // 영원히 pending
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}),
],
},
},
}
이제 Storybook에서 로딩, 에러, 정상 상태를 각각 스토리로 관리할 수 있다.
핸들러 구조화: 규모가 커질 때
프로젝트가 커지면 핸들러를 도메인별로 분리한다:
// src/mocks/handlers/users.ts
import { http, HttpResponse } from 'msw'
import { db } from '../db' // 인메모리 DB (선택사항)
export const userHandlers = [
http.get('/api/users', () => {
return HttpResponse.json(db.users.getAll())
}),
http.get('/api/users/:id', ({ params }) => {
const user = db.users.findById(params.id as string)
if (!user) return HttpResponse.json({ error: 'Not found' }, { status: 404 })
return HttpResponse.json(user)
}),
http.post('/api/users', async ({ request }) => {
const data = await request.json() as { name: string; email: string }
const user = db.users.create(data)
return HttpResponse.json(user, { status: 201 })
}),
]
// src/mocks/handlers/posts.ts
import { http, HttpResponse } from 'msw'
export const postHandlers = [
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? 1)
const limit = Number(url.searchParams.get('limit') ?? 10)
return HttpResponse.json({
data: mockPosts.slice((page - 1) * limit, page * limit),
total: mockPosts.length,
page,
limit,
})
}),
]
// src/mocks/handlers/index.ts
import { userHandlers } from './users'
import { postHandlers } from './posts'
import { authHandlers } from './auth'
export const handlers = [
...authHandlers,
...userHandlers,
...postHandlers,
]
@mswjs/data: 인메모리 DB
복잡한 CRUD 시나리오에서는 @mswjs/data로 인메모리 DB를 만들 수 있다:
npm install @mswjs/data --save-dev
// src/mocks/db.ts
import { factory, primaryKey, manyOf } from '@mswjs/data'
export const db = factory({
user: {
id: primaryKey(() => String(Date.now())),
name: String,
email: String,
role: String,
createdAt: () => new Date().toISOString(),
},
post: {
id: primaryKey(() => String(Date.now())),
title: String,
content: String,
authorId: String,
},
})
// 시드 데이터
db.user.create({ name: '김개발', email: 'dev@example.com', role: 'admin' })
db.user.create({ name: '이프론트', email: 'front@example.com', role: 'user' })
// 핸들러에서 DB 사용
http.get('/api/users', () => {
const users = db.user.getAll()
return HttpResponse.json(users)
}),
http.post('/api/users', async ({ request }) => {
const data = await request.json() as { name: string; email: string }
const user = db.user.create(data)
return HttpResponse.json(user, { status: 201 })
}),
http.delete('/api/users/:id', ({ params }) => {
db.user.delete({ where: { id: { equals: params.id as string } } })
return new HttpResponse(null, { status: 204 })
}),
이렇게 하면 테스트 간에 진짜 데이터가 추가/삭제되는 상태를 시뮬레이션할 수 있다.
자주 하는 실수들
1. 핸들러를 beforeAll 대신 beforeEach에서 초기화하기
// 잘못된 방법: 매 테스트마다 서버를 재시작 (느림)
beforeEach(() => server.listen())
afterEach(() => server.close())
// 올바른 방법
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // 핸들러만 초기화
afterAll(() => server.close())
2. URL 매칭 패턴 실수
// baseURL이 있는 경우 주의
// fetch('/api/users') → 실제 URL: http://localhost:3000/api/users
// 상대 경로로 핸들러 정의
http.get('/api/users', handler) // 올바름
// 절대 경로로도 됨
http.get('http://localhost:3000/api/users', handler)
// 와일드카드
http.get('/api/users/*', handler) // /api/users/1, /api/users/2 등
3. 응답 형식 일치
// fetch로 API를 호출할 때 실제 Response 객체처럼 동작해야 함
// HttpResponse.json()은 Content-Type: application/json 헤더 자동 추가
return HttpResponse.json(data)
// 텍스트 응답
return new HttpResponse('plain text', {
headers: { 'Content-Type': 'text/plain' },
})
// 바이너리 데이터
return HttpResponse.arrayBuffer(buffer, {
headers: { 'Content-Type': 'application/octet-stream' },
})
마무리: MSW가 표준인 이유
MSW가 API 모킹의 사실상 표준이 된 이유를 정리하면:
- HTTP 클라이언트 독립적: fetch든 axios든 같은 핸들러로 동작
- 실제 네트워크처럼: 요청 헤더, 파라미터, 바디가 진짜처럼 동작
- 재사용 가능: 개발 서버, 테스트, Storybook에서 동일한 핸들러
- 타입 안전: TypeScript와 자연스럽게 통합
- 유지보수 편리: 핸들러를 도메인별로 관리
실제 API 서버 없이도 프론트엔드 개발을 완전히 진행할 수 있고, 테스트 환경과 개발 환경의 모킹이 일치하니 "테스트에서는 됐는데 개발에서 안 된다" 문제가 사라진다.
투자할 시간이 있다면 지금 당장 도입하길 권한다.