테스팅 강좌 #4 비동기와 네트워크 모킹 — MSW로 가로채는 패턴
#3 까지의 컴포넌트는 props만 받아 그대로 렌더했습니다. 이번 글은 컴포넌트가 외부 데이터를 가져오는 경우입니다. 이 순간 두 가지 결정이 들어옵니다 — 비동기 검증을 어떻게 할까, 네트워크를 어떻게 가짜로 만들까.
테스팅 강좌에서 이번 글의 위치:
- #1 왜 테스트인가
- #2 Vitest 셋업과 첫 단위 테스트
- #3 React Testing Library
- #4 비동기와 네트워크 모킹 — MSW로 가로채는 패턴 ← 이번 글
- #5 사용자 이벤트와 폼 테스트
- #6 Playwright로 E2E와 CI 통합
이번 글은 두 가지가 짝을 이룹니다 — MSW로 네트워크 가로채기 + findBy* / waitFor로 비동기 검증.
fetch를 직접 모킹하지 마세요
#
가장 많이 보는 안티패턴부터.
import { vi } from 'vitest';
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Alice' }),
} as Response);
});이게 왜 안 좋은가:
- Response 객체의 일부만 가짜로 만듦. 코드가
response.headers.get('...')를 부르면 깨짐. - 코드가
axios를 쓰면 — 또 다른 모킹 방식이 필요.tanstack-query/swr등 추상화 위에서 도는 코드도 다 따로. - 가짜 Response의 모양이 실제와 어긋날 수 있음 — 테스트는 통과인데 프로덕션에서 깨짐.
- 한 핸들러가 다른 엔드포인트(
/posts/1vs/posts/2)에 다르게 응답하는 시나리오를 짜기 번거로움.
#1 에서 짚은 결 — 시스템 경계만 모킹. 그 경계는 fetch 함수가 아니라 네트워크 입니다. fetch가 진짜 동작하고, 그 호출이 어디로 가서 어떤 응답을 받느냐만 우리가 정의하는 게 깔끔.
MSW — 네트워크 레이어 가로채기 #
MSW(Mock Service Worker) 는 fetch / XHR / axios 무엇이든 네트워크 호출 직전에 가로채서 정의된 응답을 돌려줍니다. 코드 입장에서는 진짜 네트워크에 갔다 온 것과 구분이 안 됩니다.
설치:
pnpm add -D msw브라우저에서 동작하려면 service worker 파일이 public/에 있어야 하지만, 테스트 환경(node)에서는 그게 필요 없습니다. Node 용 setup만 잡으면 됩니다.
Handler 작성 #
가장 단순한 형태.
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: 'Alice',
});
}),
http.get('/api/posts', () => {
return HttpResponse.json([
{ id: 1, title: 'First' },
{ id: 2, title: 'Second' },
]);
}),
http.post('/api/posts', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 99, ...body }, { status: 201 });
}),
];http.get, http.post 등이 실제 HTTP 메소드와 1:1 대응. 두 번째 인자가 실제 응답을 만드는 함수입니다.
params— 경로 매개변수 (:id).request— 표준 Request 객체.request.url,request.json(),request.headers등 그대로.HttpResponse.json(...)/HttpResponse.text(...)/new HttpResponse(...)— 응답 만들기.
이게 fetch 가짜 만들기와 다른 점 입니다. 진짜 Request/Response 객체가 오갑니다. 표준 Web API 그대로니까 헷갈릴 일이 없습니다.
Server 셋업 (Node 환경) #
테스트가 시작될 때 server를 띄우고, 끝나면 정리:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/test/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());각 hook의 역할:
beforeAll(server.listen)— 모든 테스트 시작 전 한 번.onUnhandledRequest: 'error'가 중요한 옵션 — 정의 안 된 엔드포인트로 요청이 가면 즉시 에러로 알림. 누락된 핸들러를 빨리 발견.afterEach(server.resetHandlers)— 각 테스트 후 추가된 핸들러를 초기 상태로 되돌림. 한 테스트의server.use(...)가 다른 테스트에 새지 않게.afterAll(server.close)— 모든 테스트 끝난 뒤 정리합니다.
첫 비동기 테스트 — UserCard #
데이터를 fetch 해서 표시하는 컴포넌트.
import { useEffect, useState } from 'react';
type User = { id: number; name: string };
export function UserCard({ id }: { id: number }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/users/${id}`)
.then((res) => {
if (!res.ok) throw new Error('Failed to load');
return res.json();
})
.then(setUser)
.catch((e) => setError(e.message));
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!user) return <p>Loading...</p>;
return (
<article>
<h2>{user.name}</h2>
<p>id: {user.id}</p>
</article>
);
}테스트의 첫 시도:
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';
it('user 데이터를 받아 화면에 띄운다', async () => {
render(<UserCard id={1} />);
// 처음엔 Loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 데이터가 도착하면 이름이 보여야 함
expect(await screen.findByRole('heading')).toHaveTextContent('Alice');
});핵심은 findByRole. #3 에서 “잠시 후 나타나는 것” 이라고 짚은 부분입니다. 기본 1초 동안 폴링하면서 요소가 나타나기를 기다려요. 못 찾으면 그제야 에러.
getByRole('heading')으로 시도하면 — 데이터가 도착하기 전에 즉시 검사해서 실패합니다. 비동기 결과는 항상 findBy* 또는 waitFor.
waitFor — 더 일반적인 비동기 검증
#
findBy*는 “특정 요소가 나타나기를 기다림” 의 역할입니다. 더 일반적인 비동기 조건은 waitFor가 받습니다.
import { waitFor } from '@testing-library/react';
it('어떤 함수가 N 번 호출됐는지 비동기로 검증', async () => {
render(<MyComponent />);
await waitFor(() => {
expect(mockFn).toHaveBeenCalledTimes(2);
});
});waitFor의 콜백은 검증 코드. 실패하면 다시 시도, 1초간 통과 못 하면 마지막 에러를 throw.
findBy* vs waitFor의 역할:
- 화면에 요소가 나타나기를 기다림 →
findBy*가 더 간결. - DOM 외의 검증(콜백 호출 횟수, 외부 mock의 상태)이 비동기로 이루어짐 →
waitFor.
findBy* 안에는 사실 waitFor가 들어 있습니다. 같은 도구의 사용법이 다를 뿐.
Error path 테스트 — server.use로 핸들러 덮기
#
happy path만 테스트하면 절반만 본 셈. 에러 응답에서의 동작도 검증해야 합니다. 한 테스트만 다른 응답을 주려면 server.use로 그 테스트 동안만 핸들러를 덮어씁니다.
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
it('서버가 500 을 주면 에러 메시지를 띄운다', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserCard id={1} />);
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load');
});afterEach(server.resetHandlers)가 셋업에 있었으니, 이 핸들러는 이 테스트 안에서만 효과. 다음 테스트는 다시 기본 handlers의 응답을 받습니다.
다양한 시나리오 #
자주 짜는 변형들:
import { delay } from 'msw';
server.use(
http.get('/api/posts', async () => {
await delay(500); // 500ms 지연
return HttpResponse.json([]);
})
);http.post('/api/posts', async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({ title: 'Hello' });
return HttpResponse.json({ id: 1, ...body });
})server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error(); // network error
})
);http.get('/api/me', () => {
return HttpResponse.json(
{ id: 1 },
{
headers: { 'X-Total-Count': '42' },
}
);
})HttpResponse.error()는 fetch 입장에서 “TypeError: Failed to fetch” 와 같은 결과. 오프라인/CORS 실패를 시뮬레이션할 때.
TanStack Query와 함께 #
실전에서는 fetch를 직접 부르지 않고 @tanstack/react-query 같은 것이 들어옵니다. MSW와 잘 맞습니다 — MSW는 네트워크 레이어를 가로채니, query 라이브러리가 어떻게 호출하든 영향 없음.
import { useQuery } from '@tanstack/react-query';
export function UserCard({ id }: { id: number }) {
const { data, error, isPending } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p role="alert">{error.message}</p>;
return <article><h2>{data.name}</h2></article>;
}테스트할 때는 QueryClientProvider로 감싸야 합니다. 매 테스트가 깨끗한 cache로 시작하도록 새 client를 만드는 게 안전.
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function render(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // 테스트에선 retry 끄기
});
return rtlRender(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
export * from '@testing-library/react';retry: false가 중요한 설정입니다. 기본값이 3회 재시도라 — 에러 테스트에서 한 번 실패하고 끝나야 하는데 3 번 재시도하느라 timeout이 자주 일어나요.
흔한 비동기 함정 #
여기가 디버깅이 가장 까다로워요. 자주 마주치는 경우들:
act 경고 — “An update was not wrapped in act” — 비동기 변화가 awaitt 안 된 흔적. findBy* / waitFor로 확실히 기다리거나, 매 user 동작에 await를 붙였는지 확인.
테스트가 가끔 실패 (flaky) — setTimeout 이나 비결정적 시간이 들어간 코드. 가짜 타이머(vi.useFakeTimers())로 시간을 통제. 또는 waitFor의 timeout 옵션을 늘리기 (보통 시간 늘리는 건 답이 아님).
MSW의 핸들러가 안 잡힘 — URL 매칭 실패. fetch가 절대 경로로 갔는데 핸들러가 상대 경로로 정의됐거나 그 반대. onUnhandledRequest: 'error'를 켜두면 즉시 발견.
findByRole('alert')가 잡혔는데 텍스트가 비어 있음 — alert role 요소가 떠 있긴 한데 그 안의 텍스트가 비동기로 채워지는 경우입니다. waitFor(() => expect(el).toHaveTextContent('...'))로 텍스트까지 기다리기.
Loading... 검증과 데이터 검증이 같은 테스트에 섞여 있음 — render 직후 Loading을 검증하려는데, queryFn이 동기적으로 끝나면(특히 cache hit) loading 단계를 통째로 못 봄. loading 검증은 별도 테스트로 분리하고, 그곳에서 의도적으로 응답을 지연.
테스트 간에 상태가 새는 듯 — server.resetHandlers() 누락. 또는 QueryClient가 한 인스턴스로 재사용되어 cache가 남음.
디버깅 — screen.debug() + 콘솔
#
비동기 테스트가 깨졌을 때 흔한 패턴:
render(<UserCard id={1} />);
screen.debug(); // Loading 상태의 DOM
await waitFor(() => {
screen.debug(); // 매 시도마다 DOM 출력 — 무엇이 보이는지 추적
expect(screen.getByText('Alice')).toBeInTheDocument();
});또 하나 — MSW의 핸들러가 진짜 호출되고 있는지 확인.
server.events.on('request:start', ({ request }) => {
console.log('MSW intercepted:', request.method, request.url);
});정리 #
- fetch를 직접 모킹하지 말 것. 네트워크 레이어를 가로채는 MSW가 정석.
- MSW handler는 표준 Request/Response 그대로.
http.get/post(...)와HttpResponse.json(...)두 손이면 거의 다 됨. beforeAll(server.listen)/afterEach(resetHandlers)/afterAll(server.close)는 한 번만 잡아두면 끝.- 비동기 결과는
findBy*또는waitFor.getBy*로는 timing이 안 맞음. - 한 테스트에서만 다른 응답 —
server.use(...)로 덮고, afterEach가 자동 복구. - TanStack Query 등 라이브러리 위에서도 MSW가 그대로 동작. 테스트에서는
retry: false를 권장합니다. onUnhandledRequest: 'error'를 켜두면 누락된 핸들러를 빨리 발견.
다음 글(#5 사용자 이벤트와 폼 테스트)에서는 입력과 제출을 다룹니다. userEvent의 자세한 메소드들, React Hook Form 같은 폼 라이브러리 위의 테스트, 검증 에러 시나리오까지 정리합니다.