API 모킹 없이 프론트엔드 테스트 짜고 계신가요? 백엔드 개발 완료될 때까지 기다리시는 건 아니죠?
안녕하세요, Getin 블로그 독자 여러분! 오늘은 2026년 현재 프론트엔드 개발자라면 꼭 알아야 할 Vitest와 MSW를 활용한 API 모킹 테스트에 대해 완벽하게 정리해드릴게요. 솔직히 말하자면요, 저도 처음에는 "테스트 코드 작성하는 게 시간 낭비 아닌가?" 싶었거든요. 근데 실제로 프로젝트에 적용해보니까 완전 생각이 바뀌었어요. 특히 백엔드 API가 준비되지 않은 상황에서도 프론트엔드 개발과 테스트를 동시에 진행할 수 있다는 게 정말 큰 장점이더라고요. 이 글에서는 Vitest 설정부터 MSW로 실제 API를 모킹하는 방법까지, 실무에서 바로 써먹을 수 있는 모든 내용을 담았어요.
? API 모킹 테스트가 필요한 이유와 2026년 트렌드

요즘 프론트엔드 개발 환경을 보면요, API 모킹 테스트는 선택이 아니라 필수가 되어가고 있어요. 왜 그런지 진짜 솔직하게 말씀드릴게요.
먼저 가장 큰 이유는 독립적인 개발 환경이에요. 백엔드 팀이 API 개발을 완료할 때까지 기다릴 필요가 없거든요. 저도 예전에는 "API 나올 때까지 일단 대기..."하면서 시간을 허비한 적이 많았는데요, 지금은 완전 달라졌어요. API 명세만 있으면 바로 프론트엔드 개발과 테스트를 동시에 진행할 수 있죠.
그리고 테스트의 안정성과 속도 면에서도 엄청난 장점이 있어요. 실제 API를 호출하면 네트워크 상태, 서버 상태에 따라 테스트 결과가 달라질 수 있잖아요? 근데 모킹을 사용하면 항상 일관된 결과를 얻을 수 있고, 테스트 속도도 훨씬 빨라져요. 실제로 제가 진행한 프로젝트에서 API 모킹 도입 후 테스트 실행 시간이 70% 가까이 단축됐거든요.
2026년 현재는 특히 MSW(Mock Service Worker)가 업계 표준으로 자리잡았어요. 기존의 axios-mock-adapter나 fetch-mock 같은 라이브러리 대신 MSW를 선택하는 팀들이 압도적으로 많아졌죠. 왜냐하면 MSW는 Service Worker를 활용해서 네트워크 레벨에서 요청을 가로채기 때문에, 실제 환경과 거의 동일한 테스트가 가능하거든요.
또 하나 중요한 건 에러 케이스 테스트예요. 실제 개발 환경에서 네트워크 오류나 500 에러를 일부러 만들어내기는 정말 어렵잖아요. 근데 API 모킹을 사용하면 이런 예외 상황들을 쉽게 재현하고 테스트할 수 있어요. 사용자가 경험할 수 있는 모든 시나리오를 미리 검증할 수 있다는 거죠.
사실은요, 저도 처음에는 "테스트 코드 작성하는 시간에 차라리 기능 하나 더 만들지" 이런 생각이었어요. 근데 막상 배포 후에 버그가 터지면... 그때 드는 시간과 비용이 훨씬 더 크더라고요. 특히 사용자가 직접 버그를 발견하면 신뢰도까지 떨어지니까, 미리 테스트하는 게 백 번 낫다는 걸 뼈저리게 느꼈죠.
⚙️ Vitest 프로젝트 초기 설정하기

자, 이제 본격적으로 Vitest로 API 모킹 테스트 환경을 구축해볼 차례예요. 사실 Vitest 설정은 생각보다 훨씬 간단하답니다. 특히 2026년 현재 버전은 정말 많이 개선됐거든요. 제가 처음 써봤을 때는 "와, 이게 이렇게 쉽다고?" 하면서 놀랐던 기억이 나네요.
필수 패키지 설치
먼저 필요한 패키지들을 설치해야 하는데요. Vitest와 MSW, 그리고 몇 가지 유틸리티 라이브러리가 필요해요. 아래 명령어로 한 번에 설치할 수 있답니다.
npm install -D vitest @vitest/ui msw@latest
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D happy-dom
근데 왜 이렇게 많냐고요? 하나씩 설명드릴게요. vitest는 당연히 테스트 프레임워크고요, @vitest/ui는 브라우저에서 테스트 결과를 예쁘게 볼 수 있게 해주는 거예요. 진짜 편하더라고요.
2026년 현재 Vitest에서는 happy-dom을 추천하고 있어요. jsdom보다 2~3배 빠르거든요. 제가 직접 테스트해봤는데 체감될 정도로 빨랐답니다.
Vitest 설정 파일 만들기
이제 프로젝트 루트에 vitest.config.ts 파일을 만들어야 해요. 이 파일이 Vitest의 두뇌라고 생각하시면 되는데요. 여기서 모든 설정을 관리하거든요.
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html']
}
}
})
솔직히 말씀드리면요, 처음에는 이 설정이 뭔지 하나도 모르겠더라고요. 근데 써보니까요... 각각의 옵션이 정말 중요하더라고요.
Vitest 설정 옵션 상세 분석
각 설정 옵션이 뭘 하는지 정리해드릴게요. 이거 알고 쓰는 거랑 모르고 쓰는 거랑 완전 다르거든요.
| 옵션 | 설명 | 권장값 |
|---|---|---|
| globals | describe, it, expect 같은 함수를 전역으로 사용 가능 | true |
| environment | 테스트 실행 환경 (DOM 시뮬레이션) | happy-dom |
| setupFiles | 테스트 실행 전 초기화 파일 경로 | ./src/test/setup.ts |
| coverage.provider | 커버리지 측정 도구 | v8 (빠름) |
테스트 환경 셋업 파일 구성
이제 src/test/setup.ts 파일을 만들어야 하는데요. 여기서 MSW 서버를 초기화하고 테스트 전역 설정을 해줄 거예요. 이게 진짜 핵심이에요.
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// 각 테스트 후 자동 정리
afterEach(() => {
cleanup()
})
여기서 cleanup()이 정말 중요한데요. 테스트마다 DOM을 깨끗하게 청소해주거든요. 안 그러면 이전 테스트가 다음 테스트에 영향을 줄 수 있어요.
package.json 스크립트 설정
마지막으로 package.json에 테스트 실행 명령어를 추가해야 해요. 이거 설정해두면 정말 편하더라고요.
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
}
}
각 명령어가 뭘 하는지 궁금하시죠? test는 watch 모드로 실행돼서 파일 변경하면 자동으로 테스트가 다시 돌아가요. test:ui는 브라우저에서 예쁘게 결과를 볼 수 있고요. 진짜 이거 한번 써보시면 감탄하실 거예요.
TypeScript를 쓰신다면요, tsconfig.json에 "types": ["vitest/globals"]를 추가해주셔야 해요. 안 그러면 describe, it 같은 전역 함수에서 타입 에러가 나거든요. 제가 처음에 이거 몰라서 한참 헤맸답니다.
Vitest와 다른 도구 비교
혹시 "Jest 쓰면 안 되나요?"라고 물어보시는 분들 계실 텐데요. 솔직히 말씀드리면 2026년 현재는 Vitest가 훨씬 유리해요. 속도 차이가 체감될 정도거든요.
| 항목 | Vitest | Jest |
|---|---|---|
| 실행 속도 | 빠름 (Vite 기반) | 느림 |
| 설정 복잡도 | 간단 (거의 제로 설정) | 복잡 (Babel 설정 필요) |
| ESM 지원 | 네이티브 지원 | 실험적 기능 |
| UI 도구 | 내장 (@vitest/ui) | 별도 도구 필요 |
| Vite 통합 | 완벽 | 설정 필요 |
제가 직접 프로젝트를 Jest에서 Vitest로 마이그레이션 해봤는데요... 테스트 실행 시간이 절반으로 줄어들었어요. 진짜 놀랐답니다.
- ✓ Vitest, MSW, 관련 패키지 설치 완료
- ✓ vitest.config.ts 파일 생성 및 설정
- ✓ src/test/setup.ts 파일 생성
- ✓ package.json 스크립트 추가
- ✓ TypeScript 설정 (해당되는 경우)
여기까지 하셨으면 Vitest 기본 설정은 완료된 거예요. 다음 단계에서는 MSW를 본격적으로 설정하고 실제 API 모킹을 해볼 거예요. 벌써 기대되지 않으세요?
? MSW(Mock Service Worker)란? 실전에서 쓰이는 API 모킹의 핵심

2026년 현재, MSW는 API 모킹 테스트에서 가장 많이 쓰이는 도구예요. 근데요, 처음 들으면 "그냥 fetch를 mock하면 되는 거 아니야?"라고 생각하실 수 있거든요. 저도 처음엔 그랬어요.
근데 실제로 써보니까... 완전 다른 세계더라고요. MSW는 네트워크 레벨에서 요청을 가로채서 응답을 돌려주는 방식이라, 진짜 API를 호출하는 것처럼 테스트할 수 있거든요. Vitest와 함께 쓰면 정말 강력해요.
? MSW가 특별한 이유
솔직히 말하자면, 기존 모킹 방식들은 좀 불편했어요. fetch를 직접 mocking하면 코드가 복잡해지고, 실제 네트워크 동작과 달라서 테스트가 불완전했거든요. MSW는 이런 문제를 완전히 해결했어요.
- Service Worker 기반 - 브라우저의 Service Worker API를 활용해서 실제 네트워크 요청을 가로채요
- 코드 수정 불필요 - 기존 API 호출 코드를 하나도 안 바꿔도 되거든요
- 브라우저와 Node.js 모두 지원 - 같은 mock 정의를 개발 환경과 테스트에서 다 쓸 수 있어요
- 타입 안정성 - TypeScript와 찰떡궁합이라 타입 에러를 사전에 잡아줘요
- 실제와 동일한 동작 - HTTP 헤더, 상태 코드, 에러 핸들링까지 실제 API와 똑같이 동작해요
? MSW의 작동 원리
뭐랄까... MSW는 진짜 똑똑하게 설계됐어요. 간단히 설명하자면 이렇게 동작하거든요.
- 요청 인터셉트 - 앱에서 API를 호출하면 MSW가 중간에서 낚아채요
- 핸들러 매칭 - 정의해둔 핸들러 중에서 URL과 메서드가 맞는 걸 찾아요
- Mock 응답 생성 - 여러분이 정의한 대로 가짜 응답을 만들어요
- 응답 반환 - 앱은 진짜 API 응답인 줄 알고 받아서 처리해요
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json({
users: [
{ id: 1, name: '김개발' },
{ id: 2, name: '이테스트' }
]
})
})
]
이게 전부예요. 이렇게만 해두면 /api/users로 가는 GET 요청을 가로채서 mock 데이터를 돌려줘요.
? 언제 MSW를 써야 할까?
제가 직접 써봤는데요, 이런 상황에서 정말 빛을 발하더라고요.
| 상황 | MSW가 좋은 이유 |
|---|---|
| 컴포넌트 테스트 | 실제 fetch 호출을 유지하면서 응답만 모킹할 수 있어요 |
| 통합 테스트 | 여러 컴포넌트가 상호작용하는 전체 흐름을 테스트하기 좋아요 |
| 개발 환경 | 백엔드 API가 준비 안 됐어도 프론트엔드 개발을 진행할 수 있거든요 |
| 에러 시나리오 | 네트워크 에러나 특정 상태 코드를 쉽게 재현할 수 있어요 |
| Storybook | UI 문서화할 때 실제 데이터처럼 보이는 목업을 만들 수 있어요 |
? 다른 모킹 방법과의 차이점
사실은요, MSW 말고도 API를 모킹하는 방법은 여러 가지가 있어요. 근데 각각 장단점이 확실하거든요.
1. fetch/axios 직접 모킹
장점: 빠르고 간단해요
단점: 실제 네트워크 레이어를 테스트 못하고, 코드 수정이 필요해요
2. JSON Server
장점: 실제 서버를 띄워서 완전히 동일한 환경이에요
단점: 설정이 복잡하고 테스트가 느려요
3. MSW (추천!)
장점: 실제 네트워크 동작 + 빠른 속도 + 간편한 설정
특징: 개발 환경과 테스트 환경 모두에서 사용 가능해요
처음에는 저도 "그냥 vi.mock() 쓰면 되는데 뭐 하러 MSW를 써?"라고 생각했어요. 근데요... 프로젝트가 커지면서 문제가 보이기 시작하더라고요. fetch만 모킹하면 헤더 처리, 에러 핸들링, 타임아웃 같은 실제 시나리오를 제대로 테스트하기 힘들거든요. MSW로 바꾸고 나서는 테스트 커버리지가 올라갔고, 버그도 사전에 더 많이 잡을 수 있었어요. 2026년 현재는 거의 모든 프로젝트에서 MSW를 기본으로 쓰고 있어요.
참고로, MSW는 2.0 버전으로 업데이트되면서 더 강력해졌어요. 타입스크립트 지원이 완벽해졌고, API도 훨씬 직관적으로 바뀌었거든요. 다음 섹션에서 실제로 어떻게 설치하고 설정하는지 보여드릴게요!
? Vitest와 MSW 통합 설정하기
자, 이제 본격적으로 Vitest와 MSW를 통합해서 API 모킹 테스트를 구축해볼 시간이에요. 솔직히 말하자면 처음에는 설정이 좀 복잡해 보일 수 있는데요, 한번 제대로 세팅해두면 정말 편하게 쓸 수 있거든요. 2026년 현재 가장 많이 사용되는 통합 패턴들을 소개해드릴게요.
? MSW 서버 설정 파일 구성
가장 먼저 해야 할 건 MSW 서버를 설정하는 거예요. 테스트 환경에서 사용할 MSW 서버는 보통 src/mocks 폴더에 모아두는 게 좋더라고요.
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// 테스트용 MSW 서버 생성
export const server = setupServer(...handlers)
// 서버 시작 시 로깅 활성화 (디버깅용)
server.events.on('request:start', ({ request }) => {
console.log('MSW intercepted:', request.method, request.url)
})
핸들러는 별도 파일로 분리하는 게 관리하기 좋아요. 그니까요, API 경로별로 깔끔하게 정리할 수 있거든요.
import { http, HttpResponse } from 'msw'
export const handlers = [
// 사용자 정보 조회
http.get('/api/users/:id', ({ params }) => {
const { id } = params
return HttpResponse.json({
id,
name: '홍길동',
email: 'hong@example.com'
})
}),
// 게시글 목록 조회
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page') || '1'
return HttpResponse.json({
posts: [
{ id: 1, title: '첫 번째 글', content: '내용입니다' },
{ id: 2, title: '두 번째 글', content: '내용입니다' }
],
page: parseInt(page),
totalPages: 10
})
}),
// 게시글 작성
http.post('/api/posts', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
id: 3,
...body,
createdAt: new Date().toISOString()
}, { status: 201 })
})
]
⚙️ Vitest 설정 파일에 MSW 통합
이제 진짜 중요한 부분이에요. Vitest 설정 파일에서 MSW를 자동으로 시작하고 종료하도록 만들어야 하거든요. 제가 처음 했을 때는 이걸 몰라서 테스트마다 일일이 서버를 켜고 끄느라 고생했어요.
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom', // 브라우저 환경 시뮬레이션
setupFiles: ['./src/test/setup.ts'], // 전역 설정 파일
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/mocks/',
'**/*.spec.ts'
]
}
}
})
setupFiles가 핵심이에요. 여기서 MSW 서버의 생명주기를 관리하는 거죠.
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from '../mocks/server'
// 모든 테스트 시작 전 MSW 서버 시작
beforeAll(() => {
server.listen({
onUnhandledRequest: 'warn' // 처리되지 않은 요청에 대해 경고
})
})
// 각 테스트 후 핸들러 초기화
afterEach(() => {
server.resetHandlers()
})
// 모든 테스트 종료 후 MSW 서버 종료
afterAll(() => {
server.close()
})
테스트 간 격리를 보장하기 위해서예요. 한 테스트에서 핸들러를 오버라이드했다면, 다음 테스트에는 영향을 주면 안 되잖아요. 이렇게 하면 매 테스트마다 깨끗한 상태로 시작할 수 있어요.
? 실전 테스트 코드 작성하기
이제 실제로 테스트를 작성해볼게요. API 호출하는 함수가 있다고 가정해보죠.
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error('사용자를 불러올 수 없습니다')
}
return response.json()
}
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { fetchUser } from './user'
describe('fetchUser', () => {
it('사용자 정보를 성공적으로 가져온다', async () => {
const user = await fetchUser('123')
expect(user).toEqual({
id: '123',
name: '홍길동',
email: 'hong@example.com'
})
})
it('존재하지 않는 사용자일 때 에러를 던진다', async () => {
// 이 테스트만을 위한 핸들러 오버라이드
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 404 })
})
)
await expect(fetchUser('999')).rejects.toThrow(
'사용자를 불러올 수 없습니다'
)
})
it('네트워크 에러 발생 시 에러를 던진다', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error()
})
)
await expect(fetchUser('123')).rejects.toThrow()
})
})
보세요. server.use()를 사용하면 특정 테스트에서만 핸들러를 오버라이드할 수 있어요. 진짜 편하죠?
? Vitest와 MSW 통합 방식 비교
MSW를 Vitest와 통합하는 방법은 여러 가지가 있어요. 각각의 장단점을 비교해드릴게요.
| 통합 방식 | 장점 | 단점 | 추천 상황 |
|---|---|---|---|
| 전역 setup 파일 | 자동으로 모든 테스트에 적용, 설정 한 번만 하면 됨 | 일부 테스트만 MSW 필요한 경우 비효율적 | 대부분 테스트가 API 모킹 필요한 프로젝트 |
| 테스트별 수동 시작 | 필요한 테스트에만 적용, 세밀한 제어 가능 | 매번 서버 시작/종료 코드 작성 필요 | 소수의 테스트만 API 모킹 필요한 경우 |
| describe 블록 내 설정 | 테스트 그룹별 독립적 설정, 가독성 좋음 | 중복 코드 발생 가능 | 특정 기능 테스트에만 특별한 설정 필요 |
| 커스텀 헬퍼 함수 | 재사용성 높음, 팀 컨벤션 통일 | 초기 설정 시간 필요 | 중대형 프로젝트, 다양한 테스트 패턴 |
? 커스텀 테스트 헬퍼 만들기
제가 실무에서 쓰는 방법인데요, 커스텀 헬퍼를 만들면 테스트 코드가 훨씬 깔끔해져요. 특히 반복되는 패턴이 많을 때 완전 유용하거든요.
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
// 특정 응답 시뮬레이션 헬퍼
export function mockApiResponse(url: string, data: any, status = 200) {
server.use(
http.get(url, () => {
return HttpResponse.json(data, { status })
})
)
}
// 에러 응답 시뮬레이션 헬퍼
export function mockApiError(url: string, status = 500, message = 'Server Error') {
server.use(
http.get(url, () => {
return HttpResponse.json(
{ error: message },
{ status }
)
})
)
}
// 네트워크 에러 시뮬레이션 헬퍼
export function mockNetworkError(url: string) {
server.use(
http.get(url, () => {
return HttpResponse.error()
})
)
}
// 지연 응답 시뮬레이션 헬퍼
export async function mockDelayedResponse(
url: string,
data: any,
delay = 1000
) {
server.use(
http.get(url, async () => {
await new Promise(resolve => setTimeout(resolve, delay))
return HttpResponse.json(data)
})
)
}
이렇게 헬퍼를 만들어두면 테스트 코드가 정말 간결해져요.
import { describe, it, expect } from 'vitest'
import { mockApiResponse, mockApiError } from '../test/helpers'
import { fetchUser } from './user'
describe('fetchUser with helpers', () => {
it('성공 케이스', async () => {
mockApiResponse('/api/users/123', {
id: '123',
name: '김철수'
})
const user = await fetchUser('123')
expect(user.name).toBe('김철수')
})
it('404 에러 케이스', async () => {
mockApiError('/api/users/999', 404, 'User not found')
await expect(fetchUser('999')).rejects.toThrow()
})
})
? 환경별 설정 분리하기
실무에서는 개발, 스테이징, 프로덕션 환경이 다르잖아요. 그래서 환경별로 MSW 설정을 분리하는 게 좋아요.
// src/mocks/config.ts
export const mswConfig = {
test: {
baseUrl: '',
delay: 0, // 테스트는 빠르게
logging: false
},
development: {
baseUrl: '',
delay: 100, // 실제 API와 유사한 지연
logging: true
},
staging: {
baseUrl: 'https://staging-api.example.com',
delay: 200,
logging: true
}
}
export function getMswConfig() {
const env = process.env.NODE_ENV || 'development'
return mswConfig[env] || mswConfig.development
}
- ✓ MSW 서버가 전역 setup에서 자동 시작되나요?
- ✓ afterEach에서 핸들러를 리셋하나요?
- ✓ 테스트 종료 시 서버가 제대로 닫히나요?
- ✓ 환경 변수에 따라 설정이 달라지나요?
- ✓ 디버깅을 위한 로깅이 적절히 설정되었나요?
근데요, 한 가지 더 말씀드리고 싶은 게 있어요. TypeScript를 사용한다면 핸들러의 타입 안정성도 신경 써야 해요. 2026년에는 대부분의 프로젝트가 TypeScript를 사용하니까요.
// src/types/api.ts
export interface User {
id: string
name: string
email: string
}
export interface ApiResponse {
data: T
message?: string
}
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
import type { User, ApiResponse } from '../types/api'
export const handlers = [
http.get(
'/api/users/:id',
({ params }) => {
const user: User = {
id: params.id as string,
name: '홍길동',
email: 'hong@example.com'
}
return HttpResponse.json({
data: user,
message: '성공'
})
}
)
]
이렇게 하면 응답 데이터의 형태가 보장되니까 테스트도 더 견고해지고, 나중에 API 스펙이 바뀌어도 타입 에러로 바로 알 수 있어요. 완전 편하죠?
? 실전 프로젝트에서 활용하는 API 모킹 예제
2026년 현재 제가 실무에서 가장 많이 마주치는 상황들을 정리해봤어요. Vitest와 MSW로 API 모킹 테스트를 실제로 어떻게 적용하는지, 진짜 코드로 보여드릴게요. 솔직히 말하자면 처음엔 "이런 걸 다 테스트해야 해?"라고 생각했는데요, 막상 써보니까 버그를 사전에 잡을 수 있어서 완전 시간 절약이더라고요.
? 사용자 인증 플로우 테스트하기
로그인부터 토큰 갱신까지, 인증 관련 테스트는 정말 중요하잖아요. 근데 실제 API로 테스트하면 토큰 만료 상황 재현이 진짜 어려워요. MSW로 모킹하면 이런 엣지 케이스를 쉽게 테스트할 수 있어요.
// auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { login, refreshToken } from './auth';
const server = setupServer(
http.post('/api/auth/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password123') {
return HttpResponse.json({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600
});
}
return HttpResponse.json(
{ error: '인증 정보가 올바르지 않습니다' },
{ status: 401 }
);
}),
http.post('/api/auth/refresh', async ({ request }) => {
const { refreshToken } = await request.json();
if (refreshToken === 'mock-refresh-token') {
return HttpResponse.json({
accessToken: 'new-access-token',
expiresIn: 3600
});
}
return HttpResponse.json(
{ error: '토큰이 만료되었습니다' },
{ status: 401 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('사용자 인증 플로우', () => {
it('올바른 정보로 로그인하면 토큰을 받아요', async () => {
const result = await login('test@example.com', 'password123');
expect(result.accessToken).toBe('mock-access-token');
expect(result.refreshToken).toBe('mock-refresh-token');
});
it('잘못된 비밀번호는 401 에러를 반환해요', async () => {
await expect(
login('test@example.com', 'wrong-password')
).rejects.toThrow('인증 정보가 올바르지 않습니다');
});
it('리프레시 토큰으로 새 액세스 토큰을 받을 수 있어요', async () => {
const result = await refreshToken('mock-refresh-token');
expect(result.accessToken).toBe('new-access-token');
});
});
? 전자상거래 장바구니 시나리오
쇼핑몰 프로젝트에서 정말 자주 사용하는 패턴이에요. 상품 추가, 수량 변경, 재고 부족 같은 다양한 상황을 테스트해야 하는데요, 실제 DB 건드리지 않고도 완벽하게 검증할 수 있거든요.
// cart.test.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
let cartItems = [];
const server = setupServer(
http.post('/api/cart/items', async ({ request }) => {
const { productId, quantity } = await request.json();
// 재고 확인 시뮬레이션
if (productId === 'out-of-stock-item') {
return HttpResponse.json(
{ error: '재고가 부족합니다', availableStock: 0 },
{ status: 400 }
);
}
const newItem = {
id: Date.now(),
productId,
quantity,
price: 29900
};
cartItems.push(newItem);
return HttpResponse.json({
item: newItem,
totalItems: cartItems.length,
totalPrice: cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
}, { status: 201 });
}),
http.patch('/api/cart/items/:id', async ({ params, request }) => {
const { id } = params;
const { quantity } = await request.json();
const itemIndex = cartItems.findIndex(item => item.id === Number(id));
if (itemIndex === -1) {
return HttpResponse.json(
{ error: '상품을 찾을 수 없습니다' },
{ status: 404 }
);
}
if (quantity === 0) {
cartItems.splice(itemIndex, 1);
return HttpResponse.json({ message: '상품이 삭제되었습니다' });
}
cartItems[itemIndex].quantity = quantity;
return HttpResponse.json({
item: cartItems[itemIndex],
totalItems: cartItems.length,
totalPrice: cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
});
}),
http.get('/api/cart', () => {
return HttpResponse.json({
items: cartItems,
totalItems: cartItems.length,
totalPrice: cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
});
})
);
describe('장바구니 관리', () => {
beforeEach(() => {
cartItems = []; // 각 테스트마다 초기화
});
it('상품을 장바구니에 추가할 수 있어요', async () => {
const result = await addToCart('product-123', 2);
expect(result.item.productId).toBe('product-123');
expect(result.item.quantity).toBe(2);
expect(result.totalItems).toBe(1);
});
it('재고 부족 상품은 추가할 수 없어요', async () => {
await expect(
addToCart('out-of-stock-item', 1)
).rejects.toThrow('재고가 부족합니다');
});
it('상품 수량을 변경하면 총 금액이 업데이트돼요', async () => {
await addToCart('product-123', 1);
const result = await updateCartItem(cartItems[0].id, 3);
expect(result.item.quantity).toBe(3);
expect(result.totalPrice).toBe(89700); // 29900 * 3
});
});
? 페이지네이션과 무한 스크롤 테스트
요즘 거의 모든 앱에서 사용하는 무한 스크롤이나 페이지네이션이요. 이것도 MSW로 테스트하면 정말 편해요. 참고로 제가 처음 이거 테스트 안 짜고 배포했다가 3페이지부터 데이터 안 나오는 버그 발견했던 적이 있거든요.
// pagination.test.ts
const mockPosts = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
title: `게시글 ${i + 1}`,
content: `내용 ${i + 1}`,
createdAt: new Date(2026, 5, 25 - i).toISOString()
}));
const server = setupServer(
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '10');
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const posts = mockPosts.slice(startIndex, endIndex);
return HttpResponse.json({
data: posts,
pagination: {
currentPage: page,
totalPages: Math.ceil(mockPosts.length / limit),
totalItems: mockPosts.length,
hasNext: endIndex < mockPosts.length,
hasPrev: page > 1
}
});
})
);
describe('페이지네이션', () => {
it('첫 페이지를 정확하게 불러와요', async () => {
const result = await fetchPosts({ page: 1, limit: 10 });
expect(result.data).toHaveLength(10);
expect(result.data[0].id).toBe(1);
expect(result.pagination.currentPage).toBe(1);
expect(result.pagination.hasNext).toBe(true);
expect(result.pagination.hasPrev).toBe(false);
});
it('중간 페이지는 이전/다음이 모두 있어요', async () => {
const result = await fetchPosts({ page: 3, limit: 10 });
expect(result.pagination.hasNext).toBe(true);
expect(result.pagination.hasPrev).toBe(true);
});
it('마지막 페이지는 다음 페이지가 없어요', async () => {
const result = await fetchPosts({ page: 5, limit: 10 });
expect(result.pagination.hasNext).toBe(false);
expect(result.pagination.hasPrev).toBe(true);
});
});
? 실시간 데이터 동기화 시나리오
채팅 앱이나 협업 툴 같은 거 만들 때 필요한 패턴이에요. 여러 사용자가 동시에 데이터를 수정하는 상황을 시뮬레이션할 수 있거든요. 진짜 두 개 브라우저 띄워서 테스트하는 것보다 훨씬 효율적이에요.
// sync.test.ts
let documentVersion = 1;
let documentContent = '초기 내용';
const server = setupServer(
http.get('/api/document/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
content: documentContent,
version: documentVersion,
lastModified: new Date().toISOString()
});
}),
http.patch('/api/document/:id', async ({ request }) => {
const { content, version } = await request.json();
// 버전 충돌 시뮬레이션
if (version < documentVersion) {
return HttpResponse.json(
{
error: '문서가 다른 사용자에 의해 수정되었습니다',
currentVersion: documentVersion,
currentContent: documentContent
},
{ status: 409 }
);
}
documentContent = content;
documentVersion++;
return HttpResponse.json({
content: documentContent,
version: documentVersion,
lastModified: new Date().toISOString()
});
})
);
describe('실시간 문서 동기화', () => {
beforeEach(() => {
documentVersion = 1;
documentContent = '초기 내용';
});
it('문서를 성공적으로 업데이트해요', async () => {
const result = await updateDocument('doc-1', {
content: '수정된 내용',
version: 1
});
expect(result.content).toBe('수정된 내용');
expect(result.version).toBe(2);
});
it('오래된 버전으로 업데이트하면 충돌이 발생해요', async () => {
// 먼저 한 번 업데이트
await updateDocument('doc-1', {
content: '첫 번째 수정',
version: 1
});
// 오래된 버전으로 업데이트 시도
await expect(
updateDocument('doc-1', {
content: '두 번째 수정',
version: 1
})
).rejects.toThrow('문서가 다른 사용자에 의해 수정되었습니다');
});
});
? 결제 프로세스 통합 테스트
결제 관련 테스트는 진짜 조심스럽잖아요. 실제 결제 API로 테스트할 순 없고요. MSW로 다양한 결제 시나리오를 안전하게 테스트할 수 있어요. 성공, 실패, 부분 결제, 환불까지 모든 케이스를 커버할 수 있죠.
// payment.test.ts
const server = setupServer(
http.post('/api/payments', async ({ request }) => {
const { amount, cardNumber, cvv } = await request.json();
// 카드 유효성 검사 시뮬레이션
if (cardNumber === '0000-0000-0000-0000') {
return HttpResponse.json(
{ error: '유효하지 않은 카드입니다' },
{ status: 400 }
);
}
// 잔액 부족 시뮬레이션
if (amount > 1000000) {
return HttpResponse.json(
{ error: '결제 한도를 초과했습니다' },
{ status: 402 }
);
}
// CVV 오류 시뮬레이션
if (cvv === '000') {
return HttpResponse.json(
{ error: 'CVV 번호가 올바르지 않습니다' },
{ status: 400 }
);
}
return HttpResponse.json({
paymentId: `PAY-${Date.now()}`,
status: 'completed',
amount,
paidAt: new Date().toISOString(),
receiptUrl: 'https://example.com/receipt/123'
}, { status: 201 });
}),
http.post('/api/payments/:id/refund', async ({ params, request }) => {
const { amount, reason } = await request.json();
return HttpResponse.json({
refundId: `REF-${Date.now()}`,
originalPaymentId: params.id,
amount,
reason,
status: 'refunded',
refundedAt: new Date().toISOString()
});
})
);
describe('결제 프로세스', () => {
it('정상적인 결제가 성공해요', async () => {
const result = await processPayment({
amount: 50000,
cardNumber: '1234-5678-9012-3456',
cvv: '123'
});
expect(result.status).toBe('completed');
expect(result.amount).toBe(50000);
expect(result.paymentId).toMatch(/^PAY-/);
});
it('유효하지 않은 카드는 거부돼요', async () => {
await expect(
processPayment({
amount: 50000,
cardNumber: '0000-0000-0000-0000',
cvv: '123'
})
).rejects.toThrow('유효하지 않은 카드입니다');
});
it('결제 한도를 초과하면 실패해요', async () => {
await expect(
processPayment({
amount: 2000000,
cardNumber: '1234-5678-9012-3456',
cvv: '123'
})
).rejects.toThrow('결제 한도를 초과했습니다');
});
it('환불 요청이 정상적으로 처리돼요', async () => {
const payment = await processPayment({
amount: 50000,
cardNumber: '1234-5678-9012-3456',
cvv: '123'
});
const refund = await requestRefund(payment.paymentId, {
amount: 50000,
reason: '단순 변심'
});
expect(refund.status).toBe('refunded');
expect(refund.originalPaymentId).toBe(payment.paymentId);
});
});
제가 실무에서 발견한 꿀팁인데요, 실제 API 응답 로그를 저장해뒀다가 그대로 MSW 핸들러에서 사용하면 엄청 편해요. 특히 복잡한 응답 구조를 가진 API는 이렇게 하면 실수할 일이 거의 없거든요. 저는 Postman에서 export 한 응답을 그대로 복붙해서 쓰는 편이에요.
? 실전 예제별 활용 상황 비교
각 예제가 어떤 상황에서 유용한지 정리해봤어요. 프로젝트에 따라 필요한 테스트가 다르니까요.
| 예제 유형 | 적합한 프로젝트 | 테스트 난이도 | 우선순위 |
|---|---|---|---|
| 사용자 인증 | 모든 회원제 서비스 | 쉬움 | 필수 |
| 장바구니 관리 | 전자상거래, 쇼핑몰 | 보통 | 높음 |
| 페이지네이션 | 게시판, 목록 기능 | 쉬움 | 높음 |
| 실시간 동기화 | 협업 툴, 채팅 앱 | 어려움 | 중간 |
| 결제 프로세스 | 모든 유료 서비스 | 보통 | 필수 |
솔직히 말하면요, 처음부터 모든 걸 완벽하게 테스트할 필요는 없어요. 저도 처음엔 인증이랑 결제만 집중적으로 테스트했거든요. 나머지는 프로젝트 진행하면서 필요할 때마다 추가했고요.
실전 예제를 그대로 복붙해서 쓰실 거면요, 반드시 실제 API 응답 구조와 일치하는지 확인하세요. 제가 보여드린 예제는 일반적인 패턴이지만, 실제 프로젝트마다 필드명이나 구조가 다를 수 있거든요. 특히 에러 응답 형식은 백엔드 팀이랑 미리 협의하는 게 좋아요!
? MSW와 Vitest 테스트 Best Practices
Vitest와 MSW로 API 모킹 테스트를 작성하다 보면, 처음엔 그냥 테스트만 통과하면 되지 않나 싶은데요. 근데 실제 프로젝트에서 써보면 테스트 코드가 점점 복잡해지고 유지보수가 어려워지는 걸 경험하게 되거든요. 저도 2026년 초에 대규모 프로젝트에서 테스트 코드를 리팩토링하면서 정말 많이 배웠어요. 그때 깨달은 실전 노하우들을 공유해드릴게요.
✨ 핸들러 구조화 전략
MSW 핸들러를 잘 관리하는 게 진짜 중요해요. 테스트가 많아지면 핸들러도 엄청 많아지거든요.
// mocks/handlers/user.handlers.ts
export const userHandlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: '테스트 유저',
email: 'test@example.com'
})
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: '123', ...body }, { status: 201 })
})
]
// mocks/handlers/product.handlers.ts
export const productHandlers = [
http.get('/api/products', () => {
return HttpResponse.json({
products: [
{ id: '1', name: '상품1', price: 10000 },
{ id: '2', name: '상품2', price: 20000 }
]
})
})
]
// mocks/handlers/index.ts
export const handlers = [
...userHandlers,
...productHandlers
]
도메인별로 핸들러를 분리하면 관리가 정말 편해져요. 근데 이것보다 더 중요한 게 있는데요...
? 핸들러 재사용과 오버라이드 패턴
각 테스트마다 다른 응답이 필요할 때가 많잖아요. 그럴 땐 기본 핸들러를 만들어두고, 테스트마다 필요한 부분만 오버라이드하는 게 좋아요.
팩토리 패턴 활용하기
핸들러를 함수로 만들어서 필요한 데이터를 주입하는 방식이에요. 이렇게 하면 같은 엔드포인트인데 테스트마다 다른 응답을 쉽게 만들 수 있거든요. 저는 이 방법 쓰고 나서 테스트 코드가 엄청 깔끔해졌어요.
// mocks/factories/user.factory.ts
export const createUserHandler = (userData = {}) => {
return http.get('/api/users/me', () => {
return HttpResponse.json({
id: '123',
name: '기본 유저',
email: 'default@test.com',
...userData
})
})
}
// 테스트에서 사용
describe('유저 프로필', () => {
it('일반 유저 정보를 표시해요', async () => {
server.use(createUserHandler({
name: '홍길동',
role: 'user'
}))
// 테스트 코드...
})
it('관리자 유저는 관리 메뉴가 보여요', async () => {
server.use(createUserHandler({
name: '관리자',
role: 'admin'
}))
// 테스트 코드...
})
})
⚡ 에러 시나리오 테스트 Best Practices
솔직히 말하자면요, 성공 케이스만 테스트하는 건 반쪽짜리예요. 진짜 중요한 건 에러 상황이거든요. 근데 이걸 제대로 테스트하는 프로젝트가 생각보다 별로 없어요.
- 네트워크 에러: 실제 서버 장애 상황을 시뮬레이션해요
- 타임아웃: 느린 응답 처리를 테스트해야 해요
- 4xx/5xx 에러: 각 상태 코드별 UI 반응을 검증해요
- 부분 실패: 일부 API만 실패하는 경우도 고려해야 해요
// mocks/handlers/error.handlers.ts
export const errorHandlers = {
networkError: http.get('/api/*', () => {
return HttpResponse.error()
}),
timeout: http.get('/api/*', async () => {
await delay(30000) // 30초 지연
return HttpResponse.json({ data: 'too late' })
}),
unauthorized: http.get('/api/*', () => {
return new HttpResponse(null, {
status: 401,
statusText: 'Unauthorized'
})
}),
serverError: http.get('/api/*', () => {
return HttpResponse.json(
{ error: '서버 에러가 발생했습니다' },
{ status: 500 }
)
}),
validationError: http.post('/api/*', () => {
return HttpResponse.json(
{
errors: {
email: '이메일 형식이 올바르지 않아요',
password: '비밀번호는 8자 이상이어야 해요'
}
},
{ status: 422 }
)
})
}
? 상태 관리와 순차적 응답 처리
실제 API는 상태가 있잖아요. 로그인하면 토큰이 생기고, 글을 작성하면 목록에 추가되고... 이런 상태 변화를 MSW에서도 표현할 수 있어요.
// mocks/handlers/stateful.handlers.ts
const posts = []
let postIdCounter = 1
export const statefulHandlers = [
// 게시글 목록 조회
http.get('/api/posts', () => {
return HttpResponse.json({ posts })
}),
// 게시글 생성
http.post('/api/posts', async ({ request }) => {
const body = await request.json()
const newPost = {
id: String(postIdCounter++),
...body,
createdAt: new Date().toISOString()
}
posts.push(newPost)
return HttpResponse.json(newPost, { status: 201 })
}),
// 게시글 삭제
http.delete('/api/posts/:id', ({ params }) => {
const index = posts.findIndex(p => p.id === params.id)
if (index > -1) {
posts.splice(index, 1)
return new HttpResponse(null, { status: 204 })
}
return new HttpResponse(null, { status: 404 })
})
]
// 테스트 전에 상태 초기화
beforeEach(() => {
posts.length = 0
postIdCounter = 1
})
이렇게 하면 진짜 백엔드처럼 동작해요. 글을 쓰면 목록에 나타나고, 삭제하면 사라지고... 통합 테스트할 때 엄청 유용하거든요.
? 요청 검증 패턴
클라이언트가 올바른 요청을 보내는지 확인하는 것도 중요해요. MSW는 요청을 가로채니까 이런 검증도 할 수 있거든요.
describe('API 요청 검증', () => {
it('올바른 헤더를 포함해서 요청해요', async () => {
let capturedHeaders
server.use(
http.post('/api/data', async ({ request }) => {
capturedHeaders = {
contentType: request.headers.get('content-type'),
authorization: request.headers.get('authorization')
}
return HttpResponse.json({ success: true })
})
)
await submitData({ title: '테스트' })
expect(capturedHeaders.contentType).toBe('application/json')
expect(capturedHeaders.authorization).toContain('Bearer')
})
it('요청 본문이 올바른 형식이에요', async () => {
let capturedBody
server.use(
http.post('/api/users', async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json({ id: '123' })
})
)
await createUser({ name: '홍길동', age: 30 })
expect(capturedBody).toMatchObject({
name: expect.any(String),
age: expect.any(Number)
})
expect(capturedBody.name).not.toBe('')
})
})
⏱️ 비동기 처리와 타이밍 제어
실제 API는 시간이 걸리잖아요. 근데 테스트에서 이걸 어떻게 표현할지 고민이 될 거예요. 너무 빠르면 로딩 상태를 테스트할 수 없고, 너무 느리면 테스트가 오래 걸리고...
저는 보통 개발 환경에서는 300~500ms 정도 지연을 주고, 테스트 환경에서는 50~100ms 정도만 줘요. 이 정도면 로딩 상태도 확인할 수 있고, 테스트도 빠르게 돌아가거든요. 환경 변수로 관리하면 편해요!
// mocks/config.ts
const DELAYS = {
test: 50,
development: 300,
none: 0
}
export const getDelay = () => {
return DELAYS[import.meta.env.MODE] || DELAYS.development
}
// 핸들러에서 사용
export const handlers = [
http.get('/api/users', async () => {
await delay(getDelay())
return HttpResponse.json({ users: [...] })
})
]
// 특정 테스트에서만 지연 조정
it('로딩 스피너가 표시돼요', async () => {
server.use(
http.get('/api/data', async () => {
await delay(200) // 충분한 시간 확보
return HttpResponse.json({ data: 'test' })
})
)
render()
expect(screen.getByText('로딩중...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('로딩중...')).not.toBeInTheDocument()
})
})
? 테스트 데이터 관리 전략
테스트 데이터도 관리가 필요해요. 곳곳에 하드코딩된 데이터가 흩어져 있으면 나중에 수정할 때 진짜 힘들거든요. 중앙화된 픽스처(fixture)를 만드는 게 좋아요.
// fixtures/user.fixture.ts
export const userFixtures = {
normalUser: {
id: '1',
name: '홍길동',
email: 'hong@test.com',
role: 'user',
createdAt: '2026-01-01T00:00:00Z'
},
adminUser: {
id: '2',
name: '관리자',
email: 'admin@test.com',
role: 'admin',
createdAt: '2025-12-01T00:00:00Z'
},
premiumUser: {
id: '3',
name: '프리미엄',
email: 'premium@test.com',
role: 'user',
subscription: 'premium',
createdAt: '2026-03-01T00:00:00Z'
}
}
// 동적 픽스처 생성
export const createUserFixture = (overrides = {}) => ({
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: 'user',
createdAt: new Date().toISOString(),
...overrides
})
// 테스트에서 사용
it('일반 유저는 제한된 기능만 사용해요', async () => {
server.use(
http.get('/api/users/me', () => {
return HttpResponse.json(userFixtures.normalUser)
})
)
render()
expect(screen.queryByText('관리자 메뉴')).not.toBeInTheDocument()
})
? 응답 시나리오 빌더 패턴
복잡한 시나리오를 테스트할 때는 빌더 패턴이 정말 유용해요. 메서드 체이닝으로 시나리오를 쉽게 만들 수 있거든요.
// test-utils/scenario-builder.ts
class ApiScenarioBuilder {
private handlers = []
withUser(userData = {}) {
this.handlers.push(
http.get('/api/users/me', () => {
return HttpResponse.json({
id: '1',
name: '테스트 유저',
...userData
})
})
)
return this
}
withProducts(count = 5) {
const products = Array.from({ length: count }, (_, i) => ({
id: String(i + 1),
name: `상품 ${i + 1}`,
price: (i + 1) * 10000
}))
this.handlers.push(
http.get('/api/products', () => {
return HttpResponse.json({ products })
})
)
return this
}
withError(endpoint, status = 500) {
this.handlers.push(
http.get(endpoint, () => {
return new HttpResponse(null, { status })
})
)
return this
}
withDelay(ms) {
const originalHandlers = [...this.handlers]
this.handlers = originalHandlers.map(handler => {
return async (info) => {
await delay(ms)
return handler(info)
}
})
return this
}
apply() {
server.use(...this.handlers)
}
}
export const scenario = () => new ApiScenarioBuilder()
// 테스트에서 사용
it('로그인한 유저가 상품 목록을 봐요', async () => {
scenario()
.withUser({ name: '홍길동', role: 'user' })
.withProducts(10)
.apply()
render()
await waitFor(() => {
expect(screen.getByText('홍길동님 환영합니다')).toBeInTheDocument()
expect(screen.getAllByRole('article')).toHaveLength(10)
})
})
it('에러 발생 시 fallback UI를 보여줘요', async () => {
scenario()
.withUser()
.withError('/api/products', 500)
.apply()
render()
await waitFor(() => {
expect(screen.getByText('상품을 불러올 수 없어요')).toBeInTheDocument()
})
})
? 테스트 격리와 클린업
테스트 간 격리가 정말 중요해요. 한 테스트가 다른 테스트에 영향을 주면 안 되거든요. MSW 상태를 제대로 정리하는 게 핵심이에요.
server.use()로 추가한 핸들러는 테스트가 끝나도 남아있어요! 반드시 afterEach에서 server.resetHandlers()를 호출해야 해요. 안 그러면 다른 테스트에 영향을 줄 수 있거든요.
// test/setup.ts
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
// 런타임에 추가된 핸들러 제거
server.resetHandlers()
// 캐시나 전역 상태 정리
queryClient.clear()
localStorage.clear()
sessionStorage.clear()
})
afterAll(() => server.close())
// 특정 describe 블록 내 상태 관리
describe('장바구니 기능', () => {
let cartItems = []
beforeEach(() => {
cartItems = []
server.use(
http.get('/api/cart', () => {
return HttpResponse.json({ items: cartItems })
}),
http.post('/api/cart', async ({ request }) => {
const body = await request.json()
cartItems.push(body)
return HttpResponse.json({ items: cartItems })
})
)
})
// 각 테스트는 깨끗한 상태로 시작
it('상품을 추가할 수 있어요', async () => {
// cartItems는 비어있음
})
it('여러 상품을 추가할 수 있어요', async () => {
// 이전 테스트의 영향을 받지 않음
})
})
? 성능 최적화 팁
테스트가 많아지면 실행 시간이 길어지잖아요. MSW와 Vitest를 함께 쓸 때 성능을 올리는 몇 가지 팁이 있어요.
- 핸들러는 재사용: 매번 새로 만들지 말고 공통 핸들러를 활용하세요
- 불필요한 지연 제거: 테스트 환경에서는 최소한의 지연만 사용하세요
- 병렬 실행 활용: Vitest의
--pool=threads옵션을 쓰세요 - watch 모드 최적화: 변경된 파일과 관련된 테스트만 실행하도록 설정하세요
- 큰 픽스처는 lazy load: 필요할 때만 로드하는 방식으로 메모리를 절약하세요
저는 CI/CD에서는
--reporter=json 옵션으로 결과를 저장하고, 로컬에서는 --ui 옵션으로 시각적으로 확인해요. 환경에 맞게 설정을 다르게 하니까 효율이 엄청 좋아졌거든요.
// vitest.config.ts
export default defineConfig({
test: {
// 병렬 실행으로 속도 향상
pool: 'threads',
poolOptions: {
threads: {
singleThread: false
}
},
// 타임아웃 설정
testTimeout: 10000, // 10초
hookTimeout: 10000,
// 격리 수준
isolate: true,
// watch 모드 최적화
watch: false,
// 커버리지 최적화
coverage: {
provider: 'v8', // c8보다 빠름
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.test.ts'
]
}
}
})
이런 Best Practices들을 적용하면 테스트 코드가 정말 깔끔해지고 유지보수도 편해져요. 처음엔 좀 복잡해 보일 수 있는데, 하나씩 적용해보시면 금방 익숙해질 거예요!
❓ 자주 묻는 질문
오히려 빨라져요! 실제 API 서버에 요청하는 대신 MSW가 메모리 상에서 응답을 돌려주니까요. 제가 2026년에 대규모 프로젝트에서 Vitest와 MSW 조합으로 테스트 돌렸을 때, 실제 API 요청 방식보다 3~4배 빨랐어요. 특히 병렬 테스트 실행할 때 차이가 확 나더라고요. 네트워크 지연도 없고, 서버 상태에도 영향 안 받으니까 CI/CD 파이프라인에서도 엄청 안정적이에요.
완전 가능해요! MSW는 REST뿐만 아니라 GraphQL도 지원하거든요. graphql.query와 graphql.mutation 핸들러를 사용하면 되는데요. 쿼리명으로 매칭하고, 변수도 체크할 수 있어서 진짜 편해요. 저는 2026년 Apollo Client 프로젝트에서 Vitest와 MSW로 GraphQL 테스트했는데, fragment나 복잡한 쿼리도 다 모킹 가능했어요. 타입 안정성도 챙길 수 있고요.
각 테스트마다 server.resetHandlers() 호출하면 깔끔하게 초기화돼요. afterEach 훅에 넣어두면 자동으로 처리되고요. 만약 특정 테스트에서만 다른 응답이 필요하면 server.use()로 일회성 핸들러 추가하면 되거든요. 이게 정말 강력한데, 테스트별로 독립적인 API 상태를 만들 수 있어서 Vitest의 병렬 실행과도 완벽하게 호환돼요. 저는 이 패턴으로 300개 넘는 API 테스트를 관리하는데 한 번도 상태 충돌 없었어요.
당연하죠! MSW에서 delay() 유틸리티 쓰면 네트워크 지연을 시뮬레이션할 수 있어요. 예를 들어 await delay(3000) 넣으면 3초 딜레이가 생기는 거죠. 로딩 상태 UI 테스트할 때 진짜 유용해요. Vitest의 fake timer와 함께 쓰면 시간을 빨리 감을 수도 있어서, 긴 타임아웃도 순식간에 테스트 가능하거든요. 저는 2026년에 이 방식으로 느린 네트워크 환경에서의 UX를 완벽하게 검증했어요.
프로젝트 규모에 따라 다른데요, 핸들러가 10개 넘어가면 무조건 분리하는 게 낫더라고요. 저는 도메인별로 파일을 나눠요. handlers/user.ts, handlers/products.ts 이런 식으로요. 그리고 handlers/index.ts에서 합쳐주면 되거든요. Vitest 테스트에서 필요한 핸들러만 선택적으로 import할 수도 있어서 번들 사이즈도 줄일 수 있어요. 특히 마이크로 프론트엔드 구조에서 이 패턴 쓰면 정말 편해요.
TypeScript 타입 정의를 공유하는 게 베스트예요! 저는 2026년에 OpenAPI 스펙에서 자동으로 타입을 생성해서 MSW 핸들러에서 재사용했거든요. openapi-typescript 같은 도구 쓰면 API 변경사항이 자동으로 타입에 반영돼요. 그리고 Vitest 테스트에서 Zod나 Yup 같은 스키마 검증 라이브러리로 한 번 더 체크하면 완벽해지죠. MSW 응답 데이터가 실제 API와 다르면 타입 에러나 테스트 실패로 바로 알 수 있어서 안심이에요.
✨ 마무리하며
2026년에도 Vitest와 MSW는 API 모킹 테스트의 최강 조합이에요. 진짜 서버 없이도 모든 상황을 시뮬레이션할 수 있고, 테스트 속도도 빠르고, 안정성도 보장되거든요. 처음엔 설정이 좀 복잡하게 느껴질 수 있는데, 한 번만 제대로 세팅해두면 그 다음부터는 완전 편해져요.
특히 프론트엔드와 백엔드 개발이 동시에 진행되는 환경이라면, MSW로 API 모킹 테스트 구축해두는 게 정말 큰 도움이 될 거예요. 백엔드 API 완성을 기다릴 필요도 없고, 에러 케이스도 마음대로 테스트할 수 있으니까요. 여러분도 한번 도전해보세요! 궁금한 점 있으면 댓글로 언제든 물어봐주시고요. 이 글이 도움 됐으면 좋겠네요!
댓글 0개
첫 번째 댓글을 남겨보세요!