테스팅 강좌 #4 비동기와 네트워크 모킹 — MSW로 가로채는 패턴

7 분 소요

#3 까지의 컴포넌트는 props만 받아 그대로 렌더했습니다. 이번 글은 컴포넌트가 외부 데이터를 가져오는 경우입니다. 이 순간 두 가지 결정이 들어옵니다 — 비동기 검증을 어떻게 할까, 네트워크를 어떻게 가짜로 만들까.

테스팅 강좌에서 이번 글의 위치:

이번 글은 두 가지가 짝을 이룹니다 — MSW로 네트워크 가로채기 + findBy* / waitFor로 비동기 검증.

fetch를 직접 모킹하지 마세요 #

가장 많이 보는 안티패턴부터.

안티패턴 — 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/1 vs /posts/2)에 다르게 응답하는 시나리오를 짜기 번거로움.

#1 에서 짚은 결 — 시스템 경계만 모킹. 그 경계는 fetch 함수가 아니라 네트워크 입니다. fetch가 진짜 동작하고, 그 호출이 어디로 가서 어떤 응답을 받느냐만 우리가 정의하는 게 깔끔.

MSW — 네트워크 레이어 가로채기 #

MSW(Mock Service Worker) 는 fetch / XHR / axios 무엇이든 네트워크 호출 직전에 가로채서 정의된 응답을 돌려줍니다. 코드 입장에서는 진짜 네트워크에 갔다 온 것과 구분이 안 됩니다.

설치:

MSW 설치
pnpm add -D msw

브라우저에서 동작하려면 service worker 파일이 public/에 있어야 하지만, 테스트 환경(node)에서는 그게 필요 없습니다. Node 용 setup만 잡으면 됩니다.

Handler 작성 #

가장 단순한 형태.

src/test/handlers.ts
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를 띄우고, 끝나면 정리:

src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
vitest.setup.ts
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 해서 표시하는 컴포넌트.

src/components/UserCard.tsx
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>
  );
}

테스트의 첫 시도:

UserCard.test.tsx — happy path
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가 받습니다.

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로 그 테스트 동안만 핸들러를 덮어씁니다.

error path
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 라이브러리가 어떻게 호출하든 영향 없음.

UserCard with TanStack 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를 만드는 게 안전.

custom render with QueryClient
// 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())로 시간을 통제. 또는 waitFortimeout 옵션을 늘리기 (보통 시간 늘리는 건 답이 아님).

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의 핸들러가 진짜 호출되고 있는지 확인.

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 같은 폼 라이브러리 위의 테스트, 검증 에러 시나리오까지 정리합니다.

X