~19 min · vitest-mocking, msw, network, integration
Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"fetch 를 mock 하지 마. 네트워크를 mock 해."
`fetch` mocking 의 문제
네트워크 테스트의 순진한 접근은 fetch 를 직접 mock 하는 거: vi.spyOn(global, 'fetch').mockResolvedValue(...). 사소한 경우엔 통해. 코드가 wrapper (axios, ky, 커스텀 에러 핸들링 붙은 native Fetch) 쓰면 무너지고, 한 테스트가 같은 endpoint 를 다른 응답으로 두 번 부르면 무너지고, integration 테스트가 다중 네트워크 호출 걸치면 무너져.
MSW (Mock Service Worker) 는 다른 레이어에서 이걸 해결해: 어느 라이브러리가 만들었든 실제 outbound HTTP 요청을 가로채. 너는 요청 핸들러를 짜 — "뭔가 GET /api/users/:id 부르면 이 본문 반환" — MSW 가 응답해. 너의 앱 코드는 변경 없이 돌아.
큰 승리 — 같은 핸들러가 Vitest 와 Playwright 둘 다에서
MSW 가 request 레이어에서 동작하니까, 정확히 같은 핸들러 파일이 두 컨텍스트에서 작동:
Vitest 테스트: setupServer(...handlers) 가 Node 쪽 fetch 호출 가로채.
Playwright 테스트: setupWorker(...handlers) (브라우저에서) 나 route 기반 MSW 가 브라우저 쪽 fetch 호출 가로채.
fixture 한 세트, runner 두 개, 동일 계약. vi.mock 이 도달할 수 없는 레버리지 수준이야.
프로젝트별 setup
Vitest 용 표준 MSW setup:
설치: npm install -D msw.
공유 파일에 핸들러 작성 (src/mocks/handlers.ts).
핸들러 등록하는 server 인스턴스 생성 (src/mocks/server.ts).
vitest.setup.ts 에서 server.listen() 을 onUnhandledRequest: 'error' 와 연결.
onUnhandledRequest: 'error' 부분이 중요. Default 로 MSW 는 처리 안 된 요청을 진짜 네트워크로 통과시켜 — 프로덕션에선 괜찮지만 테스트에선 위험. 'error' 로 설정하면 mock 안 된 endpoint 부르는 어떤 테스트든 실패 — 정확히 네가 원하는 noise 야.
핸들러가 계약이야. 잘 이름 지은 핸들러 파일 (`src/mocks/handlers.ts`) 은 API 문서처럼 읽혀. 새 개발자가 훑어서 어떤 endpoint 가 있고 뭘 반환하는지 이해할 수 있고, 같은 파일이 너의 테스트가 의존하는 ground truth 야. 핸들러 로직을 테스트 파일에 흩뿌리지 마. 중앙화하고 필요할 때만 테스트별 오버라이드 해.
테스트별 오버라이드
대부분 테스트는 baseline 핸들러 공유. 한 테스트가 다른 응답 필요할 때 — 에러, 느린 응답, edge-case 본문 — 그 테스트 시작에서 server.use(...) 써. MSW 가 오버라이드를 그 테스트 동안 핸들러 리스트 앞에 prepend, 그리고 afterEach 의 server.resetHandlers() 가 오버라이드를 baseline 으로 되돌려.
Code
MSW 핸들러 — 선언적 API 계약·typescript
// src/mocks/handlers.ts — the canonical handler set
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '1') {
return HttpResponse.json({
id: 1,
firstName: 'Pippa',
lastName: 'Choi',
email: 'pippa@example.com',
});
}
return new HttpResponse(null, { status: 404 });
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string };
return HttpResponse.json({ id: 42, name: body.name }, { status: 201 });
}),
];
Vitest setup — MSW lifecycle hook·typescript
// src/mocks/server.ts — register handlers for Node (Vitest)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts — wire it into the test lifecycle
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // strip per-test overrides
afterAll(() => server.close());
Baseline + 테스트별 오버라이드 쓰는 테스트·typescript
// src/services/user-service.test.ts
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { getDisplayName } from './user-service';
describe('getDisplayName', () => {
it('uses the baseline handler', async () => {
// No override — uses the handler defined in src/mocks/handlers.ts
await expect(getDisplayName(1)).resolves.toBe('Pippa Choi');
});
it('handles 404 for unknown users', async () => {
await expect(getDisplayName(999)).rejects.toThrow();
});
it('handles a 500 server error (per-test override)', async () => {
server.use(
http.get('/api/users/:id', () =>
new HttpResponse('Internal Server Error', { status: 500 })
)
);
await expect(getDisplayName(1)).rejects.toThrow();
// afterEach resets handlers, so other tests still see the baseline.
});
});
setupFiles 잊지 마 — MSW lifecycle 가 거기 살아·typescript
// Common error: forgetting setupFiles
// Symptom: tests pass when run alone but fail in CI with real network errors.
//
// Check vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'], // ← MSW lives here
restoreMocks: true,
},
});
MSW 설치해. GET /api/posts/:id 에 대한 핸들러 작성 — id 1 엔 post 반환, 다른 건 다 404. getPostTitle(id) 함수에 대해 세 테스트 짜: (1) happy path 가 핸들러에서 title 반환, (2) unknown id 가 의미 있는 에러로 reject, (3) 한 테스트가 server.use(...) 로 503 시뮬레이션하고 함수가 그걸 노출하는지 단언. afterEach(() => server.resetHandlers()) 가 오버라이드를 격리하는지 확인.
Hint
(3) 테스트가 (1) 로 새면 — 즉 (1) 이 이제 503 봐 — resetHandlers 가 안 돌아가는 거야. afterAll 아니라 afterEach 에 있는지 확인.
Progress
Progress is local-only — sign in to sync across devices.