Skip to Content
WebReactReact 실무15. MSW로 서버 상태·인증·폼 통합 테스트

이번 편의 결과물: “로그인 → 책 등록 → 목록 반영”이 하나의 통합 테스트로 검증되어 통과합니다. · 다루는 개념: MSW 핸들러로 API 흐름 재현, 로그인→책 등록까지 이어지는 통합 테스트, 에러·빈 상태 시나리오 테스트

이 편에서 만드는 파일

bookshelf/src/ ├── mocks/ │ ├── handlers.js (+) 로그인·책 목록·등록 REST 핸들러 │ └── server.js (+) 테스트용 setupServer ├── router.jsx (~) 라우트 배열을 routes로 분리 export ├── test/ │ └── integration/ │ └── login-and-add-book.test.jsx (+) 통합 테스트 └── vitest.setup.js (~) MSW 서버 lifecycle 훅 추가

개념 정리

왜 fetch 자체가 아니라 네트워크를 가로채는가

14편의 단위 테스트는 booksApi.getBooks를 직접 vi.spyOn으로 바꿔치기했습니다. 화면 하나만 볼 때는 충분하지만, “로그인 → 책 등록 → 목록 반영”처럼 여러 API 호출이 이어지는 흐름을 검증하려면 호출마다 값을 지정하는 방식이 실제 요청 URL·메서드와 점점 멀어집니다.

MSW(Mock Service Worker)는 함수가 아니라 네트워크 요청 자체를 가로챕니다. 앱 코드는 api/books.js·api/auth.js가 호출하는 http://localhost:3001/books, http://localhost:3001/users를 그대로 부르고, MSW가 가로채 핸들러로 응답합니다. 테스트에서는 실제 json-server를 띄우지 않고 핸들러가 흉내 낸 응답으로 대신합니다. 03편에서 정한 경계대로, 이 계층은 “여러 요청이 이어져 앱이 의도한 순서로 동작하는가”를 봅니다.

계층가로채는 지점확인하는 것
14편 단위 테스트함수 호출(vi.spyOn)훅·스키마·컴포넌트 하나의 로직
15편 통합 테스트(MSW)네트워크 요청여러 화면·API가 연결된 시나리오
16편 E2E(Playwright)없음(실제 브라우저)실제 렌더링·네비게이션까지 포함한 전체 흐름

핸들러는 db.json 시드 데이터를 그대로 흉내 낸다

public/practice/bookshelf/db.json에는 books·users 배열이 있습니다. 핸들러는 이 데이터를 메모리에 복사해두고, GET /users?email=...엔 해당 사용자를, POST /books엔 새 항목 추가로 응답합니다. 실제 서버 없이도 CRUD가 상태를 유지하는 것처럼 동작합니다.

실습

1. 패키지 설치

npm install msw@2.15.0 --save-dev

2. 핸들러 작성

// bookshelf/src/mocks/handlers.js import { http, HttpResponse } from 'msw'; import seedBooks from '../data/books.seed.json'; const API_BASE = 'http://localhost:3001'; let books = [...seedBooks]; const users = [{ id: 1, email: 'zeno@example.com', password: 'password123', name: 'Zeno Kim' }]; export function resetMockData() { books = [...seedBooks]; } export const handlers = [ http.get(`${API_BASE}/users`, ({ request }) => { const email = new URL(request.url).searchParams.get('email'); return HttpResponse.json(users.filter((user) => user.email === email)); }), http.get(`${API_BASE}/books`, () => { return HttpResponse.json(books); }), http.post(`${API_BASE}/books`, async ({ request }) => { const newBook = await request.json(); if (!newBook.title) { return HttpResponse.json({ message: '제목은 필수입니다' }, { status: 400 }); } const created = { id: books.length + 1, status: 'wish', rating: 0, ...newBook }; books = [...books, created]; return HttpResponse.json(created, { status: 201 }); }), http.patch(`${API_BASE}/books/:id`, async ({ request, params }) => { const changes = await request.json(); books = books.map((book) => (String(book.id) === params.id ? { ...book, ...changes } : book)); return HttpResponse.json(books.find((book) => String(book.id) === params.id)); }), http.delete(`${API_BASE}/books/:id`, ({ params }) => { books = books.filter((book) => String(book.id) !== params.id); return HttpResponse.json({ id: params.id }); }), ];

GET /users?email=...는 07편의 login이 실제로 보내는 요청과 같습니다. 비밀번호는 응답 받은 뒤 login이 비교하므로, 핸들러는 이메일 일치 사용자만 돌려주면 됩니다.

3. 테스트용 서버 작성

// bookshelf/src/mocks/server.js import { setupServer } from 'msw/node'; import { handlers } from './handlers.js'; export const server = setupServer(...handlers);

4. vitest.setup.js에 MSW lifecycle 연결

// bookshelf/vitest.setup.js import '@testing-library/jest-dom/vitest'; import { beforeAll, afterEach, afterAll } from 'vitest'; import { server } from './src/mocks/server.js'; import { resetMockData } from './src/mocks/handlers.js'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { server.resetHandlers(); resetMockData(); }); afterAll(() => server.close());

onUnhandledRequest: 'error'는 핸들러 없는 요청이 실제 네트워크로 나가면 테스트를 실패시켜, 놓친 API 호출을 바로 알아채게 합니다.

5. 통합 테스트 작성

// bookshelf/src/test/integration/login-and-add-book.test.jsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, RouterProvider } from 'react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '../../context/AuthContext.jsx'; import { routes } from '../../router.jsx'; function renderApp() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const router = createMemoryRouter(routes, { initialEntries: ['/login'] }); return render( <QueryClientProvider client={queryClient}> <AuthProvider> <RouterProvider router={router} /> </AuthProvider> </QueryClientProvider>, ); } describe('로그인 후 책 등록 흐름', () => { it('로그인하고 책을 등록하면 목록에 반영된다', async () => { const user = userEvent.setup(); renderApp(); await user.type(screen.getByLabelText('이메일'), 'zeno@example.com'); await user.type(screen.getByLabelText('비밀번호'), 'password123'); await user.click(screen.getByRole('button', { name: '로그인' })); expect(await screen.findByText('내 서재')).toBeInTheDocument(); await user.click(screen.getByRole('link', { name: '새 책 등록' })); await user.type(screen.getByLabelText('제목'), '이펙티브 자바'); await user.type(screen.getByLabelText('저자'), '조슈아 블로크'); await user.type(screen.getByLabelText('쪽수'), '650'); await user.click(screen.getByRole('button', { name: '등록' })); expect(await screen.findByText('이펙티브 자바')).toBeInTheDocument(); }); it('잘못된 비밀번호로 로그인하면 에러 메시지가 보인다', async () => { const user = userEvent.setup(); renderApp(); await user.type(screen.getByLabelText('이메일'), 'zeno@example.com'); await user.type(screen.getByLabelText('비밀번호'), 'wrong-password'); await user.click(screen.getByRole('button', { name: '로그인' })); expect(await screen.findByText('이메일 또는 비밀번호가 올바르지 않습니다')).toBeInTheDocument(); }); it('책 목록이 비어 있으면 빈 상태 문구가 보인다', async () => { const { server } = await import('../../mocks/server.js'); const { http, HttpResponse } = await import('msw'); server.use(http.get('http://localhost:3001/books', () => HttpResponse.json([]))); const user = userEvent.setup(); renderApp(); await user.type(screen.getByLabelText('이메일'), 'zeno@example.com'); await user.type(screen.getByLabelText('비밀번호'), 'password123'); await user.click(screen.getByRole('button', { name: '로그인' })); expect(await screen.findByText('등록된 책이 없습니다')).toBeInTheDocument(); }); });

router.jsx가 라우트 배열을 routes로 함께 내보내면, 테스트는 같은 배열을 createMemoryRouter에 넘겨 브라우저 History 없이 같은 라우팅을 재현합니다. export const routes = [...]를 추가하고 router = createBrowserRouter(routes)로 바꿉니다.

server.use()는 특정 테스트에서만 핸들러를 임시로 덮어씁니다. afterEachresetHandlers()가 다음 테스트 전에 원래 핸들러로 되돌립니다.

6. 실행

npx vitest run src/test/integration

7. 확인

✓ 로그인 후 책 등록 흐름 > 로그인하고 책을 등록하면 목록에 반영된다 ✓ 로그인 후 책 등록 흐름 > 잘못된 비밀번호로 로그인하면 에러 메시지가 보인다 ✓ 로그인 후 책 등록 흐름 > 책 목록이 비어 있으면 빈 상태 문구가 보인다 Test Files 1 passed (1) Tests 3 passed (3)

직접 해보기

책 등록 API가 500 에러를 반환하는 시나리오를 추가하고, 화면에 “잠시 후 다시 시도하세요” 문구가 뜨는지 확인해 보세요.

server.use(http.post('http://localhost:3001/books', () => HttpResponse.json({ message: '서버 오류' }, { status: 500 })))로 핸들러를 덮어씁니다. NewBookPageuseCreateBook의 에러 상태에서 이 문구를 렌더링하도록 05·10편 코드를 확인하고, 없다면 이 편에서 추가합니다.

자주 하는 실수

증상원인고치는 법
테스트가 실제 네트워크로 요청을 보내려 한다핸들러 URL이 실제 요청 URL과 다름onUnhandledRequest: 'error'로 불일치 확인
한 테스트의 상태 변경이 다음 테스트에 영향을 준다resetHandlers·resetMockData 미호출afterEach에서 두 함수 모두 호출
로그인 후 화면 전환을 못 찾아 타임아웃된다getByText로 비동기 렌더링을 안 기다림findByText/findByRole 사용
MSW 핸들러가 두 번 등록돼 경고가 뜬다server.use() 핸들러를 리셋 안 함afterEach(() => server.resetHandlers())

확인 문제

문제 14지선다
MSW가 fetch 함수 자체를 바꿔치기하는 방식과 다른 점은 무엇입니까
문제 24지선다
onUnhandledRequest를 error로 설정하는 이유는 무엇입니까
문제 34지선다
server.use()로 추가한 핸들러를 afterEach에서 리셋해야 하는 이유는 무엇입니까
문제 44지선다
이 편의 통합 테스트가 검증하는 범위로 옳은 것은 무엇입니까
문제 54지선다
빈 상태 시나리오 테스트에서 서버 응답을 무엇으로 바꿉니까

참고 자료

Last updated on