이번 편의 결과물: “로그인 → 책 등록 → 목록 반영”이 하나의 통합 테스트로 검증되어 통과합니다. · 다루는 개념: 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-dev2. 핸들러 작성
// 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()는 특정 테스트에서만 핸들러를 임시로 덮어씁니다. afterEach의 resetHandlers()가 다음 테스트 전에 원래 핸들러로 되돌립니다.
6. 실행
npx vitest run src/test/integration7. 확인
✓ 로그인 후 책 등록 흐름 > 로그인하고 책을 등록하면 목록에 반영된다
✓ 로그인 후 책 등록 흐름 > 잘못된 비밀번호로 로그인하면 에러 메시지가 보인다
✓ 로그인 후 책 등록 흐름 > 책 목록이 비어 있으면 빈 상태 문구가 보인다
Test Files 1 passed (1)
Tests 3 passed (3)직접 해보기
책 등록 API가 500 에러를 반환하는 시나리오를 추가하고, 화면에 “잠시 후 다시 시도하세요” 문구가 뜨는지 확인해 보세요.
server.use(http.post('http://localhost:3001/books', () => HttpResponse.json({ message: '서버 오류' }, { status: 500 })))로 핸들러를 덮어씁니다. NewBookPage가 useCreateBook의 에러 상태에서 이 문구를 렌더링하도록 05·10편 코드를 확인하고, 없다면 이 편에서 추가합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 테스트가 실제 네트워크로 요청을 보내려 한다 | 핸들러 URL이 실제 요청 URL과 다름 | onUnhandledRequest: 'error'로 불일치 확인 |
| 한 테스트의 상태 변경이 다음 테스트에 영향을 준다 | resetHandlers·resetMockData 미호출 | afterEach에서 두 함수 모두 호출 |
| 로그인 후 화면 전환을 못 찾아 타임아웃된다 | getByText로 비동기 렌더링을 안 기다림 | findByText/findByRole 사용 |
| MSW 핸들러가 두 번 등록돼 경고가 뜬다 | server.use() 핸들러를 리셋 안 함 | afterEach(() => server.resetHandlers()) |