MSW 핸들러 하나로 Vitest와 Playwright 테스트 환경을 함께 구성하는 법
3분 요약:
src/mocks/handlers.ts파일 하나를 만들고,msw/node의setupServer로 Vitest에 연결한 뒤,@msw/playwright의defineNetworkFixture로 Playwright에도 연결합니다. 이후 두 환경이 동일한 목 계약을 바라보게 되어 핸들러 불일치 문제가 구조적으로 사라집니다.
프론트엔드 테스트를 짜다 보면 결국 이 질문에 부딪히게 됩니다. "백엔드가 아직 안 나왔는데 어떻게 테스트하지?" 저도 초반엔 fetch를 직접 jest.mock()으로 패치하거나, 환경변수로 API 베이스 URL을 바꿔가며 별도 목 서버를 띄웠는데—솔직히 그 방식은 설정 파일이 흩어지고, Vitest용 목과 Playwright용 목이 제각각 돌아다니면서 어느 순간 "어, 이 핸들러 여기선 왜 다르게 동작하지?"를 경험하게 됩니다.
MSW(Mock Service Worker)는 그 고민을 네트워크 계층 자체에서 해결합니다. 애플리케이션 코드는 한 줄도 건드리지 않고, 브라우저 Service Worker나 Node.js 인터셉터가 실제 HTTP 요청을 가로채서 핸들러가 정의한 응답을 돌려줍니다. 핸들러 파일 하나를 Vitest 단위 테스트와 Playwright E2E 양쪽에서 그대로 공유할 수 있다는 것이 MSW가 많은 프론트엔드 팀에서 채택되고 있는 핵심 이유입니다.
이 글은 이미 Vitest나 Playwright를 쓰고 있는 프론트엔드 개발자를 위해 MSW 2.x 기준으로 두 환경을 함께 설정하는 방법을 살펴봅니다. 핵심 개념을 잡고, 실제 코드를 보고, 실무에서 마주치는 함정까지 짚어보겠습니다.
핵심 개념
MSW가 환경마다 다르게 동작하는 방식
MSW의 설계 철학은 단순합니다. "핸들러는 하나, 진입점은 환경마다." 핸들러 정의 자체는 http, graphql, ws API로 어디서나 동일하게 작성하고, 그걸 어떻게 '켜는지'만 환경에 따라 달라집니다.
| 환경 | 사용 API | 동작 방식 |
|---|---|---|
| Node.js (Vitest 기본) | setupServer() from msw/node |
Node.js HTTP 인터셉터로 요청 가로챔 |
| 브라우저 (Vitest Browser Mode) | setupWorker() from msw/browser |
실제 Service Worker 등록 |
| Playwright | defineNetworkFixture() from @msw/playwright |
page.route() API 활용, SW 불필요 |
Service Worker(SW)란? 브라우저가 별도 스레드에서 실행하는 스크립트로, 페이지와 네트워크 사이에 위치해 요청을 가로채거나 캐시할 수 있습니다. MSW는 브라우저 환경에서 이를 인터셉터로 활용합니다.
이 구조 덕분에 handlers.ts는 환경에 무관하게 동일한 파일을 유지할 수 있습니다. Vitest에서 통과된 목 계약이 Playwright에서도 동일하게 적용되니, "단위 테스트에선 됐는데 E2E에서 왜 다르게 나오지?" 같은 상황이 줄어듭니다.
MSW 2.x — 구버전과 무엇이 달라졌나
2023년 말 MSW 2.x가 출시되면서 API 형태가 크게 바뀌었습니다. 구버전 예제를 보다가 헷갈릴 수 있으니 핵심 변경점만 짚어봅니다.
| 항목 | 1.x (구버전) | 2.x (현재) |
|---|---|---|
| HTTP 핸들러 | rest.get(...) |
http.get(...) |
| 응답 생성 | res(ctx.json(...)) |
HttpResponse.json(...) |
| 상태 코드 지정 | res(ctx.status(404), ctx.json(...)) |
HttpResponse.json(..., { status: 404 }) |
검색하면 1.x 예제가 아직 많이 나옵니다. rest나 res(ctx.json(...))가 보이면 구버전 예제라고 보면 됩니다. 신규 프로젝트라면 처음부터 2.x API로 작성하는 것이 좋습니다.
실전 적용
이제 핵심 개념을 바탕으로 실제 설정을 구성해보겠습니다. Vitest → Playwright 순서로 진행하면서 핸들러 파일 하나가 양쪽을 어떻게 연결하는지 살펴봅니다.
예시 1: 공유 핸들러 정의 — 모든 환경이 바라보는 단일 계약
가장 먼저 할 일은 핸들러 파일을 하나 만드는 것입니다. 이 파일이 Vitest와 Playwright 모두의 '목 계약서'가 됩니다. 패키지 설치부터 시작합니다.
pnpm add -D mswmsw는 테스트와 개발 서버에서만 사용하므로 -D(devDependency)로 설치합니다. 프로덕션 번들에 포함할 이유가 없습니다.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user', () =>
HttpResponse.json({ id: 1, name: 'Alice' })
),
http.post('/api/login', () =>
HttpResponse.json({ token: 'mock-token' }, { status: 200 })
),
]핸들러 경로는 상대 경로(/api/user)로 작성합니다. Vitest Node 모드에서는 요청 URL 그대로 매칭되고, Playwright에서는 playwright.config.ts에 설정된 baseURL을 기준으로 합쳐져 매칭됩니다. 즉, baseURL이 http://localhost:3000이라면 http://localhost:3000/api/user 요청에 이 핸들러가 반응합니다.
예시 2: Vitest Node 모드 설정
Vitest는 기본적으로 Node.js 환경에서 돌기 때문에 msw/node의 setupServer를 사용합니다. 실제 Service Worker가 없어도 Node HTTP 인터셉터가 그 역할을 대신합니다.
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// vitest.setup.ts (프로젝트 루트에 위치)
import { server } from './src/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
},
})setupFiles의 경로('./vitest.setup.ts')와 파일 위치(프로젝트 루트)가 일치하는지 확인하는 것이 좋습니다. src/ 안에 두었다면 경로도 맞춰 수정해야 합니다.
onUnhandledRequest: 'error'는 핸들러에 없는 요청이 나갈 때 테스트를 실패시킵니다. 처음엔 'warn'으로 시작해볼 수 있지만, 팀 프로젝트에선 'error'로 강제하는 편이 목 드리프트를 일찍 잡을 수 있어 좋습니다.
특정 테스트에서만 응답을 바꾸고 싶을 때는 server.use()로 핸들러를 오버라이드할 수 있습니다. afterEach의 resetHandlers()가 매 테스트 후 원래 핸들러로 되돌려 줍니다.
// 개별 테스트에서 에러 케이스 재현
it('로그인 실패 시 에러 메시지를 보여준다', async () => {
server.use(
http.post('/api/login', () =>
HttpResponse.json({ message: 'Unauthorized' }, { status: 401 })
)
)
// ... 테스트 본문
})예시 3: Playwright + @msw/playwright 설정
Playwright에서 MSW를 쓰는 방법이 예전엔 커뮤니티 패키지(playwright-msw)에 의존했는데, 이제 MSW 팀이 직접 관리하는 @msw/playwright가 나왔습니다. 저도 처음엔 playwright-msw를 쓰다가 MSW 2.x 마이그레이션 과정에서 http, HttpResponse API와 충돌이 생겨 한참 헤맸는데, @msw/playwright로 바꾼 뒤 깔끔하게 해결됐습니다. MSW 팀이 직접 만든 거니까 이쪽을 쓰는 것이 좋습니다.
pnpm add -D @msw/playwright참고:
@msw/playwright는 초기 릴리스 단계(v0.6.x)로 API가 변경될 수 있습니다.defineNetworkFixture함수명은 공식 GitHub에서 현재 버전 기준으로 확인하는 것을 권장합니다.
// playwright/fixtures.ts
import { test as base } from '@playwright/test'
import { defineNetworkFixture } from '@msw/playwright'
import { handlers } from '../src/mocks/handlers'
export const test = base.extend(
defineNetworkFixture({ handlers })
)
export { expect } from '@playwright/test'실제 테스트에서는 @playwright/test 대신 이 fixture를 import합니다. network fixture가 자동으로 주입되어 있어, 특정 테스트에서만 핸들러를 바꾸고 싶을 때 network.use()를 활용할 수 있습니다. Vitest의 server.use()와 완전히 동일한 패턴이라 익숙하게 쓸 수 있습니다.
// tests/user.spec.ts
import { test, expect } from '../playwright/fixtures'
import { http, HttpResponse } from 'msw'
test('사용자 정보가 화면에 렌더링된다', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('Alice')).toBeVisible()
})
test('API 오류 시 에러 메시지를 표시한다', async ({ page, network }) => {
await network.use(
http.get('/api/user', () =>
HttpResponse.json({ message: 'Not Found' }, { status: 404 })
)
)
await page.goto('/')
await expect(page.getByText('오류가 발생했습니다')).toBeVisible()
})@msw/playwright는 내부적으로 page.route()를 사용하기 때문에 Service Worker를 실제로 등록하지 않아도 됩니다. npx msw init public/으로 SW 파일을 생성할 필요가 없고, Playwright 환경만 사용한다면 해당 명령은 건너뛰어도 됩니다.
실무에서 가장 흔한 실수
-
onUnhandledRequest설정을 생략하는 것 — 설정하지 않으면 핸들러에 없는 요청이 조용히 실패하거나 실제 네트워크로 나가버려서 "왜 테스트가 이상하게 통과하지?"를 경험하게 됩니다.'error'로 시작하는 것을 권장합니다. -
server.resetHandlers()를 빠뜨리는 것 —afterEach에 리셋을 빠뜨리면 한 테스트에서 오버라이드한 핸들러가 다음 테스트에 영향을 줍니다. 테스트 순서에 따라 결과가 달라지는 불안정한 수트가 됩니다. -
커뮤니티 패키지
playwright-msw를 MSW 2.x와 함께 사용하는 것 —playwright-msw는 MSW 1.x 기반으로 작성된 커뮤니티 패키지라 MSW 2.x의http,HttpResponseAPI와 호환성 문제가 발생할 수 있습니다.@msw/playwright공식 패키지를 선택하는 것이 좋습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 단일 핸들러 재사용 | handlers.ts 하나를 Vitest와 Playwright가 공유 — 목 계약 불일치가 구조적으로 방지됨 |
| CI에서 백엔드 불필요 | 백엔드 서버 없이 프론트엔드 테스트가 완결됨. 인프라 의존성과 대기 시간 제거 |
| 결정론적 응답 | 네트워크 불안정으로 인한 flaky 테스트 원인 제거 |
| 에러 시나리오 즉시 재현 | 500, 401, 타임아웃 등 실제 환경에서 재현하기 어려운 케이스를 핸들러 오버라이드로 구성 가능 |
| 프로덕션 코드 무간섭 | 애플리케이션 코드를 수정하지 않고 네트워크 계층에서만 동작 |
flaky 테스트(Flaky Test)란? 코드 변경 없이도 어떤 실행에서는 통과하고 어떤 실행에서는 실패하는 불안정한 테스트를 말합니다. 네트워크 응답 지연이나 타임아웃이 흔한 원인 중 하나입니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 실제 API 검증 불가 | DB 쿼리, 인증 로직 등 백엔드 버그는 잡을 수 없음 | 별도 API 통합 테스트 또는 계약 테스트(Pact 등) 병행 |
| 핸들러 유지보수 비용 | API 스펙 변경 시 핸들러도 함께 수정해야 함 | OpenAPI 스펙으로 핸들러 자동 생성 도구 검토 |
| SW 시나리오 테스트 불가 | @msw/playwright는 page.route() 기반이라 SW 자체 동작 테스트엔 부적합 |
SW 관련 테스트는 별도 환경에서 진행 |
| Browser Mode 설정 복잡도 | Node 모드(setupServer)와 Browser 모드(setupWorker)가 달라 동시 지원 시 구성이 늘어남 |
대부분의 프로젝트에서 Node 모드만으로 충분한 경우가 많음 |
마치며
handlers.ts 파일 하나가 Vitest와 Playwright 양쪽에서 동일한 목 계약으로 작동합니다. 실제로 이 구조를 도입한 뒤 "단위 테스트 통과했는데 E2E는 왜 실패하지?"에서 보내던 디버깅 시간이 눈에 띄게 줄었습니다. 핸들러 드리프트 자체가 구조적으로 발생하기 어려워지기 때문입니다.
지금 바로 시작해볼 수 있는 3단계:
pnpm add -D msw로 설치한 뒤src/mocks/handlers.ts에 현재 프로젝트에서 가장 많이 쓰는 API 엔드포인트 1~2개만 옮겨볼 수 있습니다.src/mocks/server.ts와vitest.setup.ts를 만들고vitest.config.ts에setupFiles를 추가하면 기존 테스트가 MSW를 통해 돌아가는 것을 확인할 수 있습니다. 이 단계에서onUnhandledRequest: 'warn'으로 시작해 어떤 요청이 핸들러 밖에서 나가는지 먼저 파악해보면 좋습니다.- Playwright를 쓰고 있다면
pnpm add -D @msw/playwright후playwright/fixtures.ts에defineNetworkFixture를 연결해볼 수 있습니다. 기존 Playwright 테스트가 동일한 핸들러로 돌아가는 것을 바로 체감할 수 있습니다.
참고 자료
- MSW 공식 문서 — Introduction
- MSW 공식 레시피 — Vitest Browser Mode
- MSW 블로그 — Why Use Mock Service Worker?
- GitHub — mswjs/playwright (@msw/playwright 공식 패키지)
- npm — @msw/playwright
- DEV Community — Configure Vitest, MSW and Playwright in a React project (Part 1)
- DEV Community — Configure Vitest, MSW and Playwright in a React project (Part 2)
- Makepath Blog — Unit Testing a React Application with Vitest, MSW, and Playwright
- Epic Web Dev — Vitest Browser Mode vs Playwright