Vitest + MSW로 React 통합 테스트 설계하기: 유닛 테스트를 넘어 실사용 시나리오를 검증하는 법

유닛 테스트만으로는 사용자의 불만을 막을 수 없다
우리 프론트엔드 개발자들은 한 번쯤 이런 경험을 합니다. 개별 함수의 유닛 테스트는 모두 통과했는데, 정작 사용자가 폼을 제출하면 로딩 스피너가 사라지지 않거나, 에러 메시지가 엉뚱한 위치에 뜨는 현상입니다.
이 글은 Vitest와 MSW v2, React Testing Library를 결합해 실사용 시나리오를 통합 테스트로 검증하는 실전 전략을 다룹니다. TDD 철학에 관심 있다면 TDD로 프론트엔드 개발하기도 함께 읽어보시길 권합니다.
1. 유닛 테스트 vs 통합 테스트
| 구분 | 유닛 테스트 | 통합 테스트 |
|---|---|---|
| 검증 대상 | 함수/훅/순수 컴포넌트 | 컴포넌트 조합 + API 흐름 |
| 의존성 처리 | 완전 모킹 | 네트워크 레이어만 인터셉트 |
| 실행 속도 | 매우 빠름 | 빠름 |
| 버그 검출 범위 | 로직 오류 | 통합 오류, 상태 전이 오류 |
| 유지보수 비용 | 구현 변경 시 깨지기 쉬움 | 인터페이스 기반, 상대적으로 안정적 |
React Testing Library 공식 철학은 명확히 밝히고 있습니다. "The more your tests resemble the way your software is used, the more confidence they can give you."
2. Vitest를 선택한 이유
Vitest 공식 문서에서는 "Vitest aims to position itself as the Test Runner of choice for Vite projects"라고 명시합니다.
// vitest.setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll, vi } from 'vitest';
import { server } from './src/mocks/server';
afterEach(() => {
cleanup();
});
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
pool: 'threads',
},
});
onUnhandledRequest: 'error' 옵션은 특히 중요합니다. 핸들러에 등록되지 않은 API 요청이 발생하면 테스트를 즉시 실패시킵니다.
3. MSW v2 핸들러 작성법
MSW v2는 v1 대비 API가 크게 바뀌었습니다. rest.get() 대신 http.get()을 사용합니다. MSW v2 공식 문서
import { http, HttpResponse, delay } from 'msw';
const MOCK_PRODUCTS = [
{ id: 'prod-001', name: '무선 키보드', price: 89000, stock: 42 },
{ id: 'prod-002', name: '기계식 마우스', price: 65000, stock: 0 },
];
export const productHandlers = [
http.get('/api/products', async ({ request }) => {
const url = new URL(request.url);
const category = url.searchParams.get('category');
await delay(50);
const filtered = category
? MOCK_PRODUCTS.filter(p => p.id.startsWith(category))
: MOCK_PRODUCTS;
return HttpResponse.json({ data: filtered, total: filtered.length });
}),
http.get('/api/products/:productId', async ({ params }) => {
const { productId } = params;
const product = MOCK_PRODUCTS.find(p => p.id === productId);
if (!product) {
return HttpResponse.json(
{ code: 'PRODUCT_NOT_FOUND', message: '상품을 찾을 수 없습니다.' },
{ status: 404 }
);
}
return HttpResponse.json({ data: product });
}),
http.post('/api/products', async ({ request }) => {
const body = await request.json() as any;
if (!body.name || !body.price) {
return HttpResponse.json(
{ code: 'VALIDATION_ERROR', message: '필수 항목을 입력해주세요.' },
{ status: 400 }
);
}
return HttpResponse.json(
{ data: { id: `prod-${Date.now()}`, ...body } },
{ status: 201 }
);
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
export const server = setupServer(...productHandlers);
4. React Testing Library와 결합
쿼리 우선순위:
getByRole— 접근성 역할 기반getByLabelText— 폼 필드getByPlaceholderTextgetByTextgetByTestId— 마지막 수단
userEvent는 fireEvent보다 더 현실적인 사용자 인터랙션을 시뮬레이션합니다.
5. 비동기 쿼리 대기 전략
findBy* 는 getBy*의 비동기 버전입니다. 기본 타임아웃은 1000ms입니다.
const productItem = await screen.findByText('무선 키보드');
expect(productItem).toBeInTheDocument();
waitFor 는 콜백 안의 assertion이 통과할 때까지 폴링합니다.
await waitFor(() => {
expect(screen.queryByRole('status', { name: '로딩 중' })).not.toBeInTheDocument();
expect(screen.getByText('무선 키보드')).toBeInTheDocument();
});
waitFor 안에서 await를 사용하면 첫 번째 assertion이 통과한 순간 콜백이 끝나므로 사용하지 않습니다.
에러 바운더리나 Suspense 폴백을 테스트할 때는 React Error Boundary와 Suspense 폴백 패턴을 함께 참고하세요.
6. 에러 시나리오 모킹
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
import { ProductList } from '../ProductList';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
});
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
describe('ProductList 통합 테스트', () => {
it('상품 목록을 성공적으로 불러와 렌더링한다', async () => {
renderWithProviders(<ProductList />);
expect(screen.getByRole('status', { name: '로딩 중' })).toBeInTheDocument();
await screen.findByText('무선 키보드');
expect(screen.getByText('기계식 마우스')).toBeInTheDocument();
expect(screen.queryByRole('status', { name: '로딩 중' })).not.toBeInTheDocument();
});
it('API 500 에러 시 에러 메시지를 표시한다', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.json(
{ code: 'INTERNAL_ERROR', message: '서버 오류' },
{ status: 500 }
);
})
);
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('잠시 후 다시 시도해주세요');
});
});
it('네트워크 연결 실패 시 오프라인 안내를 표시한다', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.error();
})
);
renderWithProviders(<ProductList />);
await screen.findByText('네트워크 연결을 확인해주세요');
});
});
QueryClient를 테스트마다 새로 생성하는 것은 필수입니다. retry: false 설정도 잊지 말아야 합니다.
7. 폼 제출 전체 흐름 테스트
describe('ProductCreateForm 통합 테스트', () => {
const user = userEvent.setup();
async function fillAndSubmitForm(overrides = {}) {
const name = overrides.name ?? '테스트 상품';
const price = overrides.price ?? '50000';
await user.type(screen.getByLabelText('상품명'), name);
await user.type(screen.getByLabelText('판매가'), price);
await user.click(screen.getByRole('button', { name: '상품 등록' }));
}
it('필수 항목을 모두 입력하고 제출하면 성공 토스트가 표시된다', async () => {
render(<ProductCreateForm />);
await fillAndSubmitForm();
expect(screen.getByRole('button', { name: '등록 중...' })).toBeDisabled();
await screen.findByText('상품이 등록되었습니다.');
await waitFor(() => {
expect(screen.getByLabelText('상품명')).toHaveValue('');
});
});
it('서버 400 유효성 검증 에러를 폼 필드 옆에 인라인으로 표시한다', async () => {
server.use(
http.post('/api/products', () => {
return HttpResponse.json(
{
code: 'VALIDATION_ERROR',
errors: [{ field: 'price', message: '판매가는 100원 이상이어야 합니다.' }],
},
{ status: 400 }
);
})
);
render(<ProductCreateForm />);
await fillAndSubmitForm({ price: '10' });
const priceFieldGroup = screen.getByRole('group', { name: '판매가' });
await waitFor(() => {
expect(within(priceFieldGroup).getByRole('alert')).toHaveTextContent(
'판매가는 100원 이상이어야 합니다.'
);
});
});
});
8. Server-side MSW
MSW v2는 브라우저 환경뿐 아니라 Node.js 환경에서도 동일한 핸들러를 사용할 수 있습니다. MSW setupServer 문서
Vitest 환경(jsdom)은 Node.js 위에서 실행되므로, 브라우저 서비스 워커 대신 msw/node의 인터셉터가 fetch와 XMLHttpRequest를 가로챕니다.
Next.js App Router의 서버 컴포넌트나 API Route를 테스트할 때는 별도의 Node 전용 테스트 환경을 구성합니다.
// vitest.config.server.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.server.test.ts'],
setupFiles: ['./vitest.setup.server.ts'],
},
});
9. CI에서 병렬 실행 최적화
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run tests (shard ${{ matrix.shard }}/4)
run: pnpm vitest run --shard=${{ matrix.shard }}/4
추가 최적화:
isolate: false실험적 사용: 모듈 레지스트리 공유.- MSW 핸들러의
delay값 최소화: 테스트에서는delay(0). --changed플래그: PR에서 변경된 파일과 연관된 테스트만 실행.coverage.all: false: 커버리지 수집 시 전체 파일을 탐색하지 않음.
10. 테스트 커버리지 전략과 안티패턴
커버리지 100%는 버그가 없다는 증명이 아닙니다.
자주 보이는 안티패턴:
- 구현 세부사항 테스트: 컴포넌트의 내부 상태 변수 이름을 assertion하는 것.
- 모킹 과잉:
vi.mock으로 비즈니스 로직 함수까지 통째로 모킹하는 것. - 단일 테스트에 너무 많은 assertion: 어느 assertion이 문제인지 추적하기 어렵습니다.
data-testid남용: 시맨틱 쿼리로 대체할 수 있는 곳에data-testid를 쓰면 접근성 결함이 드러나지 않습니다.waitFor안에서await사용: 폴링 로직이 의도대로 동작하지 않습니다.
결론
- MSW 핸들러를 도메인별 파일로 분리하고,
onUnhandledRequest: 'error'로 누락된 모킹을 즉시 검출한다. - 각 통합 테스트마다
QueryClient인스턴스를 새로 생성하고retry: false를 설정한다. findBy*와waitFor를 시나리오에 맞게 구분해 사용한다.- 에러 시나리오를 반드시 별도 테스트 케이스로 작성한다.
- CI에서 Vitest 샤딩을 적용해 테스트 실행 시간을 제어한다.